@bodhiapp/bodhi-js 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +26 -0
  2. package/dist/bodhi-browser-ext/src/types/bodhiext.d.ts +202 -0
  3. package/dist/bodhi-browser-ext/src/types/common.d.ts +36 -0
  4. package/dist/bodhi-browser-ext/src/types/index.d.ts +6 -0
  5. package/dist/bodhi-browser-ext/src/types/protocol.d.ts +223 -0
  6. package/dist/bodhi-js-sdk/core/src/direct-client-base.d.ts +129 -0
  7. package/dist/bodhi-js-sdk/core/src/errors.d.ts +23 -0
  8. package/dist/bodhi-js-sdk/core/src/facade-client-base.d.ts +130 -0
  9. package/dist/bodhi-js-sdk/core/src/index.d.ts +19 -0
  10. package/dist/bodhi-js-sdk/core/src/interface.d.ts +228 -0
  11. package/dist/bodhi-js-sdk/core/src/logger.d.ts +13 -0
  12. package/dist/bodhi-js-sdk/core/src/oauth.d.ts +45 -0
  13. package/dist/bodhi-js-sdk/core/src/onboarding/config.d.ts +10 -0
  14. package/dist/bodhi-js-sdk/core/src/onboarding/index.d.ts +5 -0
  15. package/dist/bodhi-js-sdk/core/src/onboarding/modal.d.ts +80 -0
  16. package/dist/bodhi-js-sdk/core/src/onboarding/protocol-utils.d.ts +33 -0
  17. package/dist/bodhi-js-sdk/core/src/platform.d.ts +11 -0
  18. package/dist/bodhi-js-sdk/core/src/storage.d.ts +81 -0
  19. package/dist/bodhi-js-sdk/core/src/types/api.d.ts +34 -0
  20. package/dist/bodhi-js-sdk/core/src/types/callback.d.ts +23 -0
  21. package/dist/bodhi-js-sdk/core/src/types/client-state.d.ts +191 -0
  22. package/dist/bodhi-js-sdk/core/src/types/config.d.ts +26 -0
  23. package/dist/bodhi-js-sdk/core/src/types/html.d.ts +9 -0
  24. package/dist/bodhi-js-sdk/core/src/types/index.d.ts +15 -0
  25. package/dist/bodhi-js-sdk/core/src/types/platform.d.ts +16 -0
  26. package/dist/bodhi-js-sdk/core/src/types/user-info.d.ts +59 -0
  27. package/dist/bodhi-js-sdk/web/src/constants.d.ts +9 -0
  28. package/dist/bodhi-js-sdk/web/src/direct-client.d.ts +24 -0
  29. package/dist/bodhi-js-sdk/web/src/ext-client.d.ts +151 -0
  30. package/dist/bodhi-js-sdk/web/src/facade-client.d.ts +43 -0
  31. package/dist/bodhi-js-sdk/web/src/index.d.ts +5 -0
  32. package/dist/bodhi-js-sdk/web/src/interface.d.ts +4 -0
  33. package/dist/bodhi-web.cjs.js +749 -0
  34. package/dist/bodhi-web.cjs.js.map +1 -0
  35. package/dist/bodhi-web.esm.d.ts +1 -0
  36. package/dist/bodhi-web.esm.js +749 -0
  37. package/dist/bodhi-web.esm.js.map +1 -0
  38. package/dist/setup-modal/src/types/extension.d.ts +24 -0
  39. package/dist/setup-modal/src/types/index.d.ts +20 -0
  40. package/dist/setup-modal/src/types/lna.d.ts +56 -0
  41. package/dist/setup-modal/src/types/message-types.d.ts +169 -0
  42. package/dist/setup-modal/src/types/platform.d.ts +32 -0
  43. package/dist/setup-modal/src/types/protocol.d.ts +71 -0
  44. package/dist/setup-modal/src/types/server.d.ts +63 -0
  45. package/dist/setup-modal/src/types/state.d.ts +43 -0
  46. package/dist/setup-modal/src/types/type-guards.d.ts +27 -0
  47. package/package.json +54 -0
@@ -0,0 +1,749 @@
1
+ import { DirectClientBase, STORAGE_PREFIXES, generateCodeVerifier, generateCodeChallenge, isApiResultOperationError, isApiResultSuccess, createStorageKeys, EXTENSION_STATE_NOT_INITIALIZED, Logger, createOAuthEndpoints, NOOP_STATE_CALLBACK, EXTENSION_STATE_NOT_FOUND, PENDING_EXTENSION_READY, BACKEND_SERVER_NOT_REACHABLE, extractUserInfo, refreshAccessToken, createOperationError, backendServerNotReady, SERVER_ERROR_CODES, createApiError, BaseFacadeClient } from "@bodhiapp/bodhi-js-core";
2
+ class DirectWebClient extends DirectClientBase {
3
+ constructor(config, onStateChange) {
4
+ super({ ...config, storagePrefix: STORAGE_PREFIXES.DIRECT }, "DirectWebClient", onStateChange);
5
+ this.redirectUri = config.redirectUri;
6
+ }
7
+ // ============================================================================
8
+ // Authentication (Browser Redirect OAuth)
9
+ // ============================================================================
10
+ async login() {
11
+ const existingAuth = await this.getAuthState();
12
+ if (existingAuth.isLoggedIn) {
13
+ return existingAuth;
14
+ }
15
+ const resourceScope = await this.requestResourceAccess();
16
+ const fullScope = `openid profile email roles ${this.userScope} ${resourceScope}`;
17
+ const codeVerifier = generateCodeVerifier();
18
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
19
+ const state = generateCodeVerifier();
20
+ localStorage.setItem(this.storageKeys.CODE_VERIFIER, codeVerifier);
21
+ localStorage.setItem(this.storageKeys.STATE, state);
22
+ const authUrl = new URL(this.authEndpoints.authorize);
23
+ authUrl.searchParams.set("client_id", this.authClientId);
24
+ authUrl.searchParams.set("response_type", "code");
25
+ authUrl.searchParams.set("redirect_uri", this.redirectUri);
26
+ authUrl.searchParams.set("scope", fullScope);
27
+ authUrl.searchParams.set("code_challenge", codeChallenge);
28
+ authUrl.searchParams.set("code_challenge_method", "S256");
29
+ authUrl.searchParams.set("state", state);
30
+ window.location.href = authUrl.toString();
31
+ throw new Error("Redirect initiated");
32
+ }
33
+ async handleOAuthCallback(code, state) {
34
+ const storedState = localStorage.getItem(this.storageKeys.STATE);
35
+ if (!storedState || storedState !== state) {
36
+ throw new Error("Invalid state parameter - possible CSRF attack");
37
+ }
38
+ await this.exchangeCodeForTokens(code);
39
+ localStorage.removeItem(this.storageKeys.CODE_VERIFIER);
40
+ localStorage.removeItem(this.storageKeys.STATE);
41
+ const authState = await this.getAuthState();
42
+ if (!authState.isLoggedIn) {
43
+ throw new Error("Login failed");
44
+ }
45
+ const result = authState;
46
+ this.setAuthState(result);
47
+ return result;
48
+ }
49
+ async logout() {
50
+ const refreshToken = localStorage.getItem(this.storageKeys.REFRESH_TOKEN);
51
+ if (refreshToken) {
52
+ try {
53
+ const params = new URLSearchParams({
54
+ token: refreshToken,
55
+ client_id: this.authClientId,
56
+ token_type_hint: "refresh_token"
57
+ });
58
+ await fetch(this.authEndpoints.revoke, {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/x-www-form-urlencoded"
62
+ },
63
+ body: params
64
+ });
65
+ } catch (error) {
66
+ this.logger.warn("Token revocation failed:", error);
67
+ }
68
+ }
69
+ localStorage.removeItem(this.storageKeys.ACCESS_TOKEN);
70
+ localStorage.removeItem(this.storageKeys.REFRESH_TOKEN);
71
+ localStorage.removeItem(this.storageKeys.EXPIRES_AT);
72
+ localStorage.removeItem(this.storageKeys.RESOURCE_SCOPE);
73
+ const result = {
74
+ isLoggedIn: false
75
+ };
76
+ this.setAuthState(result);
77
+ return result;
78
+ }
79
+ // ============================================================================
80
+ // OAuth Helper Methods
81
+ // ============================================================================
82
+ async requestResourceAccess() {
83
+ const response = await this.sendApiRequest(
84
+ "POST",
85
+ "/bodhi/v1/apps/request-access",
86
+ { app_client_id: this.authClientId },
87
+ {},
88
+ false
89
+ );
90
+ if (isApiResultOperationError(response)) {
91
+ throw new Error("Failed to get resource access scope from server");
92
+ }
93
+ if (!isApiResultSuccess(response)) {
94
+ throw new Error("Failed to get resource access scope from server: API error");
95
+ }
96
+ const scope = response.body.scope;
97
+ localStorage.setItem(this.storageKeys.RESOURCE_SCOPE, scope);
98
+ return scope;
99
+ }
100
+ async exchangeCodeForTokens(code) {
101
+ const codeVerifier = localStorage.getItem(this.storageKeys.CODE_VERIFIER);
102
+ if (!codeVerifier) {
103
+ throw new Error("Code verifier not found");
104
+ }
105
+ const response = await fetch(this.authEndpoints.token, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/x-www-form-urlencoded"
109
+ },
110
+ body: new URLSearchParams({
111
+ grant_type: "authorization_code",
112
+ code,
113
+ redirect_uri: this.redirectUri,
114
+ client_id: this.authClientId,
115
+ code_verifier: codeVerifier
116
+ })
117
+ });
118
+ if (!response.ok) {
119
+ const errorText = await response.text();
120
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
121
+ }
122
+ const tokens = await response.json();
123
+ localStorage.setItem(this.storageKeys.ACCESS_TOKEN, tokens.access_token);
124
+ if (tokens.refresh_token) {
125
+ localStorage.setItem(this.storageKeys.REFRESH_TOKEN, tokens.refresh_token);
126
+ }
127
+ if (tokens.expires_in) {
128
+ const expiresAt = Date.now() + tokens.expires_in * 1e3;
129
+ localStorage.setItem(this.storageKeys.EXPIRES_AT, expiresAt.toString());
130
+ }
131
+ }
132
+ // ============================================================================
133
+ // Storage Implementation (localStorage)
134
+ // ============================================================================
135
+ async _storageGet(key) {
136
+ return localStorage.getItem(key);
137
+ }
138
+ async _storageSet(items) {
139
+ Object.entries(items).forEach(([key, value]) => {
140
+ localStorage.setItem(key, String(value));
141
+ });
142
+ }
143
+ async _storageRemove(keys) {
144
+ keys.forEach((key) => localStorage.removeItem(key));
145
+ }
146
+ _getRedirectUri() {
147
+ return this.redirectUri;
148
+ }
149
+ }
150
+ const POLL_INTERVAL = 500;
151
+ const POLL_TIMEOUT = 5e3;
152
+ const STORAGE_KEYS = createStorageKeys(STORAGE_PREFIXES.WEB);
153
+ class WindowBodhiextClient {
154
+ constructor(authClientId, config, onStateChange) {
155
+ this.state = EXTENSION_STATE_NOT_INITIALIZED;
156
+ this.bodhiext = null;
157
+ this.refreshPromise = null;
158
+ this.logger = new Logger("WindowBodhiextClient", config.logLevel);
159
+ this.authClientId = authClientId;
160
+ this.config = config;
161
+ this.authEndpoints = createOAuthEndpoints(this.config.authServerUrl);
162
+ this.onStateChange = onStateChange ?? NOOP_STATE_CALLBACK;
163
+ }
164
+ /**
165
+ * Set client state and notify callback
166
+ */
167
+ setState(newState) {
168
+ this.state = newState;
169
+ this.logger.info(`{state: ${JSON.stringify(newState)}} - Setting client state`);
170
+ this.onStateChange({ type: "client-state", state: newState });
171
+ }
172
+ /**
173
+ * Set auth state and notify callback
174
+ */
175
+ setAuthState(authState) {
176
+ this.onStateChange({ type: "auth-state", state: authState });
177
+ }
178
+ /**
179
+ * Set or update the state change callback
180
+ */
181
+ setStateCallback(callback) {
182
+ this.onStateChange = callback;
183
+ }
184
+ // ============================================================================
185
+ // Extension Communication
186
+ // ============================================================================
187
+ /**
188
+ * Ensure bodhiext is available, attempting to acquire it if not already set
189
+ * @throws Error if client not initialized
190
+ */
191
+ ensureBodhiext() {
192
+ if (!this.bodhiext && window.bodhiext) {
193
+ this.logger.info("Acquiring window.bodhiext reference");
194
+ this.bodhiext = window.bodhiext;
195
+ }
196
+ if (!this.bodhiext) {
197
+ throw new Error("Client not initialized");
198
+ }
199
+ }
200
+ /**
201
+ * Send extension request via window.bodhiext.sendExtRequest
202
+ */
203
+ async sendExtRequest(action, params) {
204
+ this.ensureBodhiext();
205
+ return this.bodhiext.sendExtRequest(action, params);
206
+ }
207
+ /**
208
+ * Send API message via window.bodhiext.sendApiRequest
209
+ * Converts ApiResponse to ApiResponseResult
210
+ */
211
+ async sendApiRequest(method, endpoint, body, headers, authenticated) {
212
+ try {
213
+ this.ensureBodhiext();
214
+ } catch (err) {
215
+ return {
216
+ error: {
217
+ message: err instanceof Error ? err.message : String(err),
218
+ type: "extension_error"
219
+ }
220
+ };
221
+ }
222
+ try {
223
+ let requestHeaders = headers || {};
224
+ if (authenticated) {
225
+ const accessToken = await this._getAccessTokenRaw();
226
+ if (!accessToken) {
227
+ return {
228
+ error: {
229
+ message: "Not authenticated. Please log in first.",
230
+ type: "extension_error"
231
+ }
232
+ };
233
+ }
234
+ requestHeaders = {
235
+ ...requestHeaders,
236
+ Authorization: `Bearer ${accessToken}`
237
+ };
238
+ }
239
+ const response = await this.bodhiext.sendApiRequest(
240
+ method,
241
+ endpoint,
242
+ body,
243
+ requestHeaders
244
+ );
245
+ return response;
246
+ } catch (e) {
247
+ const errorObj = e == null ? void 0 : e.error;
248
+ const message = (errorObj == null ? void 0 : errorObj.message) ?? (e instanceof Error ? e.message : String(e));
249
+ const errorType = (errorObj == null ? void 0 : errorObj.type) || "extension_error";
250
+ return {
251
+ error: {
252
+ message,
253
+ type: errorType
254
+ }
255
+ };
256
+ }
257
+ }
258
+ /**
259
+ * Get current client state
260
+ */
261
+ getState() {
262
+ return this.state;
263
+ }
264
+ isClientInitialized() {
265
+ return this.state.extension === "ready";
266
+ }
267
+ isServerReady() {
268
+ return this.isClientInitialized() && this.state.server.status === "ready";
269
+ }
270
+ /**
271
+ * Initialize extension discovery with optional timeout
272
+ * Returns ExtensionState with extension and server status
273
+ *
274
+ * Note: Web mode uses stateless discovery (always polls for window.bodhiext)
275
+ * No extensionId storage/restoration needed - window.bodhiext handle is ephemeral
276
+ */
277
+ async init(params = {}) {
278
+ var _a, _b, _c, _d;
279
+ if (!params.testConnection && !params.selectedConnection) {
280
+ this.logger.info("No testConnection or selectedConnection, returning not-initialized state");
281
+ return EXTENSION_STATE_NOT_INITIALIZED;
282
+ }
283
+ if (this.bodhiext && !params.testConnection) {
284
+ this.logger.debug("Already have bodhiext handle, skipping polling");
285
+ return this.state;
286
+ }
287
+ if (!this.bodhiext) {
288
+ const timeoutMs = params.timeoutMs ?? ((_b = (_a = this.config.initParams) == null ? void 0 : _a.extension) == null ? void 0 : _b.timeoutMs) ?? POLL_TIMEOUT;
289
+ const intervalMs = params.intervalMs ?? ((_d = (_c = this.config.initParams) == null ? void 0 : _c.extension) == null ? void 0 : _d.intervalMs) ?? POLL_INTERVAL;
290
+ const startTime = Date.now();
291
+ const found = await new Promise((resolve) => {
292
+ const check = () => {
293
+ if (window.bodhiext) {
294
+ this.bodhiext = window.bodhiext;
295
+ resolve(true);
296
+ return;
297
+ }
298
+ if (Date.now() - startTime >= timeoutMs) {
299
+ resolve(false);
300
+ return;
301
+ }
302
+ setTimeout(check, intervalMs);
303
+ };
304
+ check();
305
+ });
306
+ if (!found) {
307
+ this.logger.warn(`Extension discovery timed out`);
308
+ this.setState(EXTENSION_STATE_NOT_FOUND);
309
+ return this.state;
310
+ }
311
+ }
312
+ const extensionId = await this.bodhiext.getExtensionId();
313
+ this.logger.info(`Extension discovered: ${extensionId}`);
314
+ const state = {
315
+ type: "extension",
316
+ extension: "ready",
317
+ extensionId,
318
+ server: PENDING_EXTENSION_READY
319
+ };
320
+ if (params.testConnection) {
321
+ try {
322
+ const serverState = await this.getServerState();
323
+ this.setState({ ...state, server: serverState });
324
+ this.logger.info(`Server connectivity tested: ${serverState.status}`);
325
+ } catch (error) {
326
+ this.logger.error(`Failed to get server state:`, error);
327
+ this.setState({ ...state, server: BACKEND_SERVER_NOT_REACHABLE });
328
+ }
329
+ } else {
330
+ this.setState(state);
331
+ }
332
+ return this.state;
333
+ }
334
+ // ============================================================================
335
+ // OAuth Methods
336
+ // ============================================================================
337
+ /**
338
+ * Request resource access scope from backend
339
+ * Required for authenticated API access
340
+ */
341
+ async requestResourceAccess() {
342
+ this.ensureBodhiext();
343
+ const response = await this.bodhiext.sendApiRequest("POST", "/bodhi/v1/apps/request-access", {
344
+ app_client_id: this.authClientId
345
+ });
346
+ if (!isApiResultSuccess(response)) {
347
+ throw new Error("Failed to get resource access scope: API error");
348
+ }
349
+ const scope = response.body.scope;
350
+ localStorage.setItem(STORAGE_KEYS.RESOURCE_SCOPE, scope);
351
+ return scope;
352
+ }
353
+ /**
354
+ * Login via browser redirect OAuth2 + PKCE flow
355
+ * @returns AuthLoggedIn (though in practice, this redirects and never returns)
356
+ */
357
+ async login() {
358
+ const existingAuth = await this.getAuthState();
359
+ if (existingAuth.isLoggedIn) {
360
+ return existingAuth;
361
+ }
362
+ this.ensureBodhiext();
363
+ const resourceScope = await this.requestResourceAccess();
364
+ const codeVerifier = generateCodeVerifier();
365
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
366
+ const state = generateCodeVerifier();
367
+ localStorage.setItem(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);
368
+ localStorage.setItem(STORAGE_KEYS.STATE, state);
369
+ const scopes = ["openid", "profile", "email", "roles", this.config.userScope, resourceScope];
370
+ const params = new URLSearchParams({
371
+ response_type: "code",
372
+ client_id: this.authClientId,
373
+ redirect_uri: this.config.redirectUri,
374
+ scope: scopes.join(" "),
375
+ state,
376
+ code_challenge: codeChallenge,
377
+ code_challenge_method: "S256"
378
+ });
379
+ const authUrl = `${this.authEndpoints.authorize}?${params}`;
380
+ window.location.href = authUrl;
381
+ return new Promise(() => {
382
+ });
383
+ }
384
+ /**
385
+ * Handle OAuth callback with authorization code
386
+ * Should be called from callback page with extracted URL params
387
+ * @returns AuthLoggedIn with login state and user info
388
+ */
389
+ async handleOAuthCallback(code, state) {
390
+ const storedState = localStorage.getItem(STORAGE_KEYS.STATE);
391
+ if (!storedState || storedState !== state) {
392
+ throw new Error("Invalid state parameter - possible CSRF attack");
393
+ }
394
+ await this.exchangeCodeForTokens(code);
395
+ localStorage.removeItem(STORAGE_KEYS.CODE_VERIFIER);
396
+ localStorage.removeItem(STORAGE_KEYS.STATE);
397
+ const authState = await this.getAuthState();
398
+ if (!authState.isLoggedIn) {
399
+ throw new Error("Login failed");
400
+ }
401
+ this.setAuthState(authState);
402
+ return authState;
403
+ }
404
+ /**
405
+ * Exchange authorization code for tokens
406
+ */
407
+ async exchangeCodeForTokens(code) {
408
+ const codeVerifier = localStorage.getItem(STORAGE_KEYS.CODE_VERIFIER);
409
+ if (!codeVerifier) {
410
+ throw new Error("Code verifier not found");
411
+ }
412
+ const params = new URLSearchParams({
413
+ grant_type: "authorization_code",
414
+ client_id: this.authClientId,
415
+ code,
416
+ redirect_uri: this.config.redirectUri,
417
+ code_verifier: codeVerifier
418
+ });
419
+ const response = await fetch(this.authEndpoints.token, {
420
+ method: "POST",
421
+ headers: {
422
+ "Content-Type": "application/x-www-form-urlencoded"
423
+ },
424
+ body: params
425
+ });
426
+ if (!response.ok) {
427
+ const errorText = await response.text();
428
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
429
+ }
430
+ const tokenData = await response.json();
431
+ if (!tokenData.access_token) {
432
+ throw new Error("No access token received");
433
+ }
434
+ localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, tokenData.access_token);
435
+ if (tokenData.refresh_token) {
436
+ localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, tokenData.refresh_token);
437
+ }
438
+ if (tokenData.expires_in) {
439
+ const expiresAt = Date.now() + tokenData.expires_in * 1e3;
440
+ localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());
441
+ }
442
+ }
443
+ /**
444
+ * Logout user and revoke tokens
445
+ * @returns AuthLoggedOut with logged out state
446
+ */
447
+ async logout() {
448
+ const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
449
+ if (refreshToken) {
450
+ try {
451
+ const params = new URLSearchParams({
452
+ token: refreshToken,
453
+ client_id: this.authClientId,
454
+ token_type_hint: "refresh_token"
455
+ });
456
+ await fetch(this.authEndpoints.revoke, {
457
+ method: "POST",
458
+ headers: {
459
+ "Content-Type": "application/x-www-form-urlencoded"
460
+ },
461
+ body: params
462
+ });
463
+ } catch (error) {
464
+ this.logger.warn("Token revocation failed:", error);
465
+ }
466
+ }
467
+ localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
468
+ localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
469
+ localStorage.removeItem(STORAGE_KEYS.EXPIRES_AT);
470
+ localStorage.removeItem(STORAGE_KEYS.CODE_VERIFIER);
471
+ localStorage.removeItem(STORAGE_KEYS.STATE);
472
+ localStorage.removeItem(STORAGE_KEYS.RESOURCE_SCOPE);
473
+ const result = {
474
+ isLoggedIn: false
475
+ };
476
+ this.setAuthState(result);
477
+ return result;
478
+ }
479
+ /**
480
+ * Get current authentication state
481
+ */
482
+ async getAuthState() {
483
+ const accessToken = await this._getAccessTokenRaw();
484
+ if (!accessToken) {
485
+ return { isLoggedIn: false };
486
+ }
487
+ try {
488
+ const userInfo = extractUserInfo(accessToken);
489
+ return { isLoggedIn: true, userInfo, accessToken };
490
+ } catch (error) {
491
+ this.logger.error("Failed to parse token:", error);
492
+ return { isLoggedIn: false };
493
+ }
494
+ }
495
+ /**
496
+ * Get current access token
497
+ * Returns null if not logged in or token expired
498
+ */
499
+ async _getAccessTokenRaw() {
500
+ const accessToken = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
501
+ const expiresAt = localStorage.getItem(STORAGE_KEYS.EXPIRES_AT);
502
+ if (!accessToken) {
503
+ return null;
504
+ }
505
+ if (expiresAt) {
506
+ const expirationTime = parseInt(expiresAt, 10);
507
+ if (Date.now() >= expirationTime - 5 * 1e3) {
508
+ const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
509
+ if (refreshToken) {
510
+ return this._tryRefreshToken(refreshToken);
511
+ }
512
+ return null;
513
+ }
514
+ }
515
+ return accessToken;
516
+ }
517
+ /**
518
+ * Try to refresh access token using refresh token
519
+ * Race condition prevention: Returns existing promise if refresh already in progress
520
+ */
521
+ async _tryRefreshToken(refreshToken) {
522
+ if (this.refreshPromise) {
523
+ this.logger.debug("Refresh already in progress, returning existing promise");
524
+ return this.refreshPromise;
525
+ }
526
+ this.refreshPromise = this._doRefreshToken(refreshToken);
527
+ try {
528
+ return await this.refreshPromise;
529
+ } finally {
530
+ this.refreshPromise = null;
531
+ }
532
+ }
533
+ /**
534
+ * Perform the actual token refresh
535
+ */
536
+ async _doRefreshToken(refreshToken) {
537
+ this.logger.debug("Refreshing access token");
538
+ try {
539
+ const tokens = await refreshAccessToken(
540
+ this.authEndpoints.token,
541
+ refreshToken,
542
+ this.authClientId
543
+ );
544
+ if (tokens) {
545
+ this._storeRefreshedTokens(tokens);
546
+ const userInfo = extractUserInfo(tokens.access_token);
547
+ this.setAuthState({
548
+ isLoggedIn: true,
549
+ userInfo,
550
+ accessToken: tokens.access_token
551
+ });
552
+ this.logger.info("Token refreshed successfully");
553
+ return tokens.access_token;
554
+ }
555
+ } catch (error) {
556
+ this.logger.warn("Token refresh failed:", error);
557
+ }
558
+ this.logger.warn("Token refresh failed, keeping tokens for manual retry");
559
+ throw createOperationError(
560
+ "Access token expired and unable to refresh. Try logging out and logging in again.",
561
+ "token_refresh_failed"
562
+ );
563
+ }
564
+ /**
565
+ * Store refreshed tokens
566
+ */
567
+ _storeRefreshedTokens(tokens) {
568
+ const expiresAt = Date.now() + tokens.expires_in * 1e3;
569
+ localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);
570
+ localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, String(expiresAt));
571
+ if (tokens.refresh_token) {
572
+ localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);
573
+ }
574
+ }
575
+ /**
576
+ * Ping API
577
+ */
578
+ async pingApi() {
579
+ return this.sendApiRequest("GET", "/ping");
580
+ }
581
+ /**
582
+ * Fetch models
583
+ */
584
+ async fetchModels() {
585
+ return this.sendApiRequest(
586
+ "GET",
587
+ "/v1/models",
588
+ void 0,
589
+ void 0,
590
+ true
591
+ );
592
+ }
593
+ /**
594
+ * Get backend server state
595
+ * Calls /bodhi/v1/info and returns structured server state
596
+ */
597
+ async getServerState() {
598
+ const result = await this.sendApiRequest("GET", "/bodhi/v1/info");
599
+ if (isApiResultOperationError(result)) {
600
+ return BACKEND_SERVER_NOT_REACHABLE;
601
+ }
602
+ if (!isApiResultSuccess(result)) {
603
+ return BACKEND_SERVER_NOT_REACHABLE;
604
+ }
605
+ const body = result.body;
606
+ switch (body.status) {
607
+ case "ready":
608
+ return { status: "ready", version: body.version || "unknown" };
609
+ case "setup":
610
+ return backendServerNotReady("setup", body.version || "unknown");
611
+ case "resource-admin":
612
+ return backendServerNotReady("resource-admin", body.version || "unknown");
613
+ case "error":
614
+ return backendServerNotReady(
615
+ "error",
616
+ body.version || "unknown",
617
+ body.error ? { message: body.error.message, type: body.error.type } : SERVER_ERROR_CODES.SERVER_NOT_READY
618
+ );
619
+ default:
620
+ return BACKEND_SERVER_NOT_REACHABLE;
621
+ }
622
+ }
623
+ /**
624
+ * Generic streaming via window.bodhiext.sendStreamRequest
625
+ * Wraps ReadableStream as AsyncGenerator
626
+ */
627
+ async *stream(method, endpoint, body, headers, authenticated = true) {
628
+ this.ensureBodhiext();
629
+ let requestHeaders = headers || {};
630
+ if (authenticated) {
631
+ const accessToken = await this._getAccessTokenRaw();
632
+ if (!accessToken) {
633
+ throw new Error("Not authenticated. Please log in first.");
634
+ }
635
+ requestHeaders = {
636
+ ...requestHeaders,
637
+ Authorization: `Bearer ${accessToken}`
638
+ };
639
+ }
640
+ const stream = this.bodhiext.sendStreamRequest(method, endpoint, body, requestHeaders);
641
+ const reader = stream.getReader();
642
+ try {
643
+ while (true) {
644
+ const { value, done } = await reader.read();
645
+ if (done || (value == null ? void 0 : value.done)) {
646
+ break;
647
+ }
648
+ yield value.body;
649
+ }
650
+ } catch (err) {
651
+ if (err instanceof Error) {
652
+ if ("response" in err) {
653
+ const apiErr = err;
654
+ throw createApiError(err.message, apiErr.response.status, apiErr.response.body);
655
+ }
656
+ if ("error" in err) {
657
+ throw createOperationError(err.message, "extension_error");
658
+ }
659
+ throw createOperationError(err.message, "extension_error");
660
+ }
661
+ throw err;
662
+ } finally {
663
+ reader.releaseLock();
664
+ }
665
+ }
666
+ /**
667
+ * Chat streaming
668
+ */
669
+ async *streamChat(model, prompt, authenticated = true) {
670
+ yield* this.stream(
671
+ "POST",
672
+ "/v1/chat/completions",
673
+ {
674
+ model,
675
+ messages: [{ role: "user", content: prompt }],
676
+ stream: true
677
+ },
678
+ void 0,
679
+ authenticated
680
+ );
681
+ }
682
+ /**
683
+ * Serialize web extension client state (all transient, nothing to persist)
684
+ */
685
+ serialize() {
686
+ return {
687
+ extensionId: this.state.type === "extension" && this.state.extension === "ready" ? this.state.extensionId : void 0
688
+ };
689
+ }
690
+ /**
691
+ * Debug dump of WindowBodhiextClient internal state
692
+ */
693
+ async debug() {
694
+ return {
695
+ type: "WindowBodhiextClient",
696
+ state: this.state,
697
+ authState: await this.getAuthState(),
698
+ bodhiextAvailable: this.bodhiext !== null,
699
+ authClientId: this.authClientId,
700
+ authServerUrl: this.config.authServerUrl,
701
+ redirectUri: this.config.redirectUri,
702
+ userScope: this.config.userScope
703
+ };
704
+ }
705
+ }
706
+ class WebUIClient extends BaseFacadeClient {
707
+ constructor(authClientId, config, onStateChange, storagePrefix) {
708
+ const normalizedConfig = {
709
+ redirectUri: config.redirectUri,
710
+ authServerUrl: config.authServerUrl || "https://id.getbodhi.app/realms/bodhi",
711
+ userScope: config.userScope || "scope_user_user",
712
+ logLevel: config.logLevel || "warn",
713
+ initParams: config.initParams
714
+ };
715
+ super(authClientId, normalizedConfig, onStateChange, storagePrefix);
716
+ }
717
+ createLogger(config) {
718
+ return new Logger("WebUIClient", config.logLevel);
719
+ }
720
+ createExtClient(config, onStateChange) {
721
+ return new WindowBodhiextClient(this.authClientId, config, onStateChange);
722
+ }
723
+ createDirectClient(authClientId, config, onStateChange) {
724
+ return new DirectWebClient(
725
+ {
726
+ authClientId,
727
+ authServerUrl: config.authServerUrl,
728
+ redirectUri: config.redirectUri,
729
+ userScope: config.userScope,
730
+ logLevel: config.logLevel,
731
+ storagePrefix: STORAGE_PREFIXES.WEB
732
+ },
733
+ onStateChange
734
+ );
735
+ }
736
+ // ============================================================================
737
+ // Web-specific OAuth Callback
738
+ // ============================================================================
739
+ async handleOAuthCallback(code, state) {
740
+ if (this.connectionMode === "direct") {
741
+ return this.directClient.handleOAuthCallback(code, state);
742
+ }
743
+ return this.extClient.handleOAuthCallback(code, state);
744
+ }
745
+ }
746
+ export {
747
+ WebUIClient
748
+ };
749
+ //# sourceMappingURL=bodhi-web.esm.js.map