@bodhiapp/bodhi-js 0.0.2 → 0.0.3

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