@cstar.help/js 0.17.4 → 0.18.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.
@@ -1,4 +1,4 @@
1
- export { A as ApiErrorBody, C as CStarAuthenticationError, a as CStarConflictError, b as CStarError, c as CStarFeatureDisabledError, d as CStarNotFoundError, e as CStarPermissionError, f as CStarRateLimitError, g as CStarRealtimeUnavailableError, h as CStarServerError, i as CStarValidationError, E as ErrorType, p as parseError, j as parsePublicError } from '../errors-b5A4wfAg.js';
1
+ export { A as ApiErrorBody, C as CStarAuthenticationError, a as CStarConflictError, b as CStarError, c as CStarFeatureDisabledError, d as CStarNotFoundError, e as CStarPermissionError, f as CStarRateLimitError, g as CStarRealtimeUnavailableError, h as CStarServerError, i as CStarValidationError, E as ErrorType, p as parseError, j as parsePublicError } from '../errors-bW8CwTn4.js';
2
2
 
3
3
  /** Configuration for the AuthClient. */
4
4
  interface AuthConfig {
@@ -6,6 +6,14 @@ interface AuthConfig {
6
6
  teamSlug: string;
7
7
  /** Override the base URL (defaults to https://www.cstar.help). */
8
8
  baseUrl?: string;
9
+ /**
10
+ * Supabase project URL used to send the signup confirmation email.
11
+ * Defaults to cStar's production project. Required when `baseUrl` points
12
+ * anywhere other than production, so the email comes from the same project.
13
+ */
14
+ supabaseUrl?: string;
15
+ /** Supabase publishable (anon) key that pairs with `supabaseUrl`. */
16
+ supabaseKey?: string;
9
17
  }
10
18
  /** A customer identity returned from auth endpoints. */
11
19
  interface AuthCustomer {
@@ -17,26 +25,33 @@ interface AuthCustomer {
17
25
  interface SignupParams {
18
26
  /** Customer email address. */
19
27
  email: string;
20
- /** Password (minimum 6 characters). */
21
- password: string;
22
28
  /** Customer display name. */
23
29
  name?: string;
30
+ /**
31
+ * Page the confirmation link returns to. Defaults to the current page in a
32
+ * browser. Its origin must be on the team's allowed redirect list, or cStar
33
+ * falls back to the hosted widget page.
34
+ */
35
+ returnUrl?: string;
24
36
  }
25
- /** Response from a successful signup. */
37
+ /**
38
+ * Response from signup. It is identical for new and existing emails, so a
39
+ * signup form cannot be used to discover who already has an account. The
40
+ * customer confirms through the emailed link; call `completeSignIn()` on the
41
+ * page the link returns to.
42
+ */
26
43
  interface SignupResult {
27
- customer: AuthCustomer;
28
- /** Access token for authenticated requests. Null if email confirmation is required. */
29
- accessToken: string | null;
30
- /** Refresh token for renewing the session. Null if email confirmation is required. */
31
- refreshToken: string | null;
32
- }
33
- /** Response when email confirmation is required after signup. */
34
- interface SignupConfirmationResult {
35
44
  success: true;
36
45
  message: string;
37
46
  requiresConfirmation: true;
38
47
  }
39
- /** Parameters for logging in an existing customer. */
48
+ /** @deprecated Signup always requires confirmation now. Use `SignupResult`. */
49
+ type SignupConfirmationResult = SignupResult;
50
+ /**
51
+ * Parameters for logging in an existing customer. Signup is password-less,
52
+ * so this works only after the customer has chosen a password (see
53
+ * `AuthClient.setPassword()`).
54
+ */
40
55
  interface LoginParams {
41
56
  /** Customer email address. */
42
57
  email: string;
@@ -69,47 +84,83 @@ interface AuthSession {
69
84
  *
70
85
  * const auth = new AuthClient({ teamSlug: 'acme' });
71
86
  *
72
- * // Sign up a new customer
73
- * const result = await auth.signup({
74
- * email: 'jane@example.com',
75
- * password: 'securepass123',
76
- * name: 'Jane Doe',
77
- * });
87
+ * // Sign up: cStar emails a sign-in link. No password yet.
88
+ * await auth.signup({ email: 'jane@example.com', name: 'Jane Doe' });
78
89
  *
79
- * // Log in an existing customer
80
- * const session = await auth.login({
81
- * email: 'jane@example.com',
82
- * password: 'securepass123',
83
- * });
90
+ * // On the page the link returns to
91
+ * const session = await auth.completeSignIn();
84
92
  *
85
- * // Check current session
86
- * const current = auth.getSession();
93
+ * // Optional: choose a password for faster sign-in next time
94
+ * await auth.setPassword('securepass123');
95
+ *
96
+ * // Later visits: log in with that password
97
+ * await auth.login({ email: 'jane@example.com', password: 'securepass123' });
87
98
  * ```
88
99
  */
89
100
  declare class AuthClient {
90
101
  private readonly baseUrl;
91
102
  private readonly teamSlug;
103
+ private readonly supabaseUrl;
104
+ private readonly supabaseKey;
92
105
  private session;
106
+ private pkceClient;
93
107
  constructor(config: AuthConfig);
94
108
  /**
95
- * Create a new customer account.
109
+ * Create a password-less customer account and email a sign-in link.
110
+ *
111
+ * The response is the same whether or not the email already has an
112
+ * account. Nothing is signed in yet: the customer clicks the link, and the
113
+ * page it returns to calls `completeSignIn()`. Signup takes no password,
114
+ * because one chosen before the link proves the address could belong to
115
+ * someone else. Once signed in, the customer can call `setPassword()` to
116
+ * enable `login()`.
96
117
  *
97
- * If the team requires email confirmation, returns a `SignupConfirmationResult`
98
- * instead of tokens. Check for `requiresConfirmation` in the response.
118
+ * Runs in a browser: the PKCE verifier is stored in this page's
119
+ * localStorage so `completeSignIn()` can finish the exchange.
99
120
  *
100
- * @throws Error on validation failure, duplicate email, or server error
121
+ * @throws CStarRateLimitError when too many signups were attempted
101
122
  */
102
- signup(params: SignupParams): Promise<SignupResult | SignupConfirmationResult>;
123
+ signup(params: SignupParams): Promise<SignupResult>;
124
+ /**
125
+ * Finish sign-in on the page the confirmation link returns to.
126
+ *
127
+ * Reads `?code=` and `?type=` from the URL, removes them from the address
128
+ * bar, exchanges the code, and stores the session. Returns null when the URL
129
+ * holds no cStar confirmation code or the exchange fails.
130
+ *
131
+ * @param url Defaults to `window.location.href`.
132
+ */
133
+ completeSignIn(url?: string): Promise<AuthSession | null>;
134
+ /**
135
+ * Choose a password for the signed-in customer, so later visits can use
136
+ * `login()` instead of an emailed link.
137
+ *
138
+ * Needs a session from `completeSignIn()` or `login()`. This is the same
139
+ * Supabase Auth password update the hosted widget makes after a first
140
+ * email sign-in, and it clears the widget's "choose a password" prompt.
141
+ * The customer stays signed in.
142
+ *
143
+ * @throws CStarValidationError when the password is under 8 characters or is rejected
144
+ * @throws CStarAuthenticationError when there is no session or it has expired
145
+ */
146
+ setPassword(password: string): Promise<void>;
103
147
  /**
104
148
  * Log in an existing customer with email and password.
105
149
  *
150
+ * Signup is password-less, so this works only after the customer has
151
+ * chosen a password with `setPassword()` or in the hosted widget.
152
+ *
106
153
  * On success, stores the session internally so subsequent
107
154
  * ChatClient requests can use `auth.getSession()` for tokens.
108
155
  *
109
156
  * @throws Error on invalid credentials, unconfirmed email, or server error
110
157
  */
111
158
  login(params: LoginParams): Promise<LoginResult>;
112
- /** Clear the stored session. */
159
+ /**
160
+ * Clear the session: the in-memory one and, when a sign-in link or
161
+ * setPassword() restored it through Supabase Auth, the copy that client
162
+ * keeps in browser storage, so the next person on the device is signed out.
163
+ */
113
164
  logout(): void;
114
165
  /** Get the current session, or null if not authenticated. */
115
166
  getSession(): AuthSession | null;
@@ -119,6 +170,7 @@ declare class AuthClient {
119
170
  get isAuthenticated(): boolean;
120
171
  /** Get the current access token, or null. Useful for passing to ChatClient. */
121
172
  get accessToken(): string | null;
173
+ private getPkceClient;
122
174
  private fetch;
123
175
  }
124
176
 
@@ -269,45 +269,196 @@ function parseError(statusCode, body, retryAfter) {
269
269
 
270
270
  // src/auth/index.ts
271
271
  var DEFAULT_BASE_URL = "https://www.cstar.help";
272
+ var PRODUCTION_ORIGINS = /* @__PURE__ */ new Set(["https://www.cstar.help", "https://cstar.help"]);
273
+ var PRODUCTION_SUPABASE_URL = "https://kmorvgcubqthkfrasbny.supabase.co";
274
+ var PRODUCTION_SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imttb3J2Z2N1YnF0aGtmcmFzYm55Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUzMzczNjIsImV4cCI6MjA4MDkxMzM2Mn0.e0RJbMpZr-8FbJmAGhdw86Ym9M4MCfvX9Qm6Va3d_N8";
275
+ var PKCE_STORAGE_KEY = "cstar_widget_pkce";
276
+ var MIN_PASSWORD_LENGTH = 8;
277
+ var CONFIRMATION_TYPES = /* @__PURE__ */ new Set(["magiclink", "signup", "recovery"]);
272
278
  var AuthClient = class {
273
279
  constructor(config) {
274
280
  this.session = null;
281
+ this.pkceClient = null;
275
282
  this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
276
283
  this.teamSlug = config.teamSlug;
284
+ const isProduction = PRODUCTION_ORIGINS.has(this.baseUrl);
285
+ this.supabaseUrl = config.supabaseUrl ?? (isProduction ? PRODUCTION_SUPABASE_URL : null);
286
+ this.supabaseKey = config.supabaseKey ?? (isProduction ? PRODUCTION_SUPABASE_KEY : null);
277
287
  }
278
288
  /**
279
- * Create a new customer account.
289
+ * Create a password-less customer account and email a sign-in link.
280
290
  *
281
- * If the team requires email confirmation, returns a `SignupConfirmationResult`
282
- * instead of tokens. Check for `requiresConfirmation` in the response.
291
+ * The response is the same whether or not the email already has an
292
+ * account. Nothing is signed in yet: the customer clicks the link, and the
293
+ * page it returns to calls `completeSignIn()`. Signup takes no password,
294
+ * because one chosen before the link proves the address could belong to
295
+ * someone else. Once signed in, the customer can call `setPassword()` to
296
+ * enable `login()`.
283
297
  *
284
- * @throws Error on validation failure, duplicate email, or server error
298
+ * Runs in a browser: the PKCE verifier is stored in this page's
299
+ * localStorage so `completeSignIn()` can finish the exchange.
300
+ *
301
+ * @throws CStarRateLimitError when too many signups were attempted
285
302
  */
286
303
  async signup(params) {
287
- const response = await this.fetch(
288
- "/api/widget/auth/signup",
289
- {
290
- method: "POST",
291
- body: {
292
- teamId: this.teamSlug,
293
- email: params.email,
294
- password: params.password,
295
- name: params.name
296
- }
304
+ const returnUrl = params.returnUrl ?? (typeof window !== "undefined" && window.location ? window.location.href : void 0);
305
+ const prepared = await this.fetch("/api/widget/auth/signup", {
306
+ method: "POST",
307
+ body: {
308
+ teamId: this.teamSlug,
309
+ email: params.email,
310
+ name: params.name,
311
+ returnUrl
297
312
  }
298
- );
299
- if ("accessToken" in response && response.accessToken && response.customer) {
313
+ });
314
+ if (!prepared?.emailRedirectTo) {
315
+ throw parsePublicError(500, null, "Sign-up prepare returned no redirect URL.");
316
+ }
317
+ const client = await this.getPkceClient();
318
+ const { error } = await client.auth.signInWithOtp({
319
+ email: params.email,
320
+ options: { emailRedirectTo: prepared.emailRedirectTo, shouldCreateUser: false }
321
+ });
322
+ if (error) {
323
+ if (error.status === 429) {
324
+ throw parsePublicError(
325
+ 429,
326
+ { error: "Too many sign-up requests. Please wait a few minutes and try again." },
327
+ "Too many sign-up requests"
328
+ );
329
+ }
330
+ throw parsePublicError(
331
+ 502,
332
+ {
333
+ error: "Couldn't send a confirmation email. Try again, or sign in if you already have an account."
334
+ },
335
+ "Confirmation email failed"
336
+ );
337
+ }
338
+ return {
339
+ success: true,
340
+ message: prepared.message ?? "Check your inbox to finish signing up.",
341
+ requiresConfirmation: true
342
+ };
343
+ }
344
+ /**
345
+ * Finish sign-in on the page the confirmation link returns to.
346
+ *
347
+ * Reads `?code=` and `?type=` from the URL, removes them from the address
348
+ * bar, exchanges the code, and stores the session. Returns null when the URL
349
+ * holds no cStar confirmation code or the exchange fails.
350
+ *
351
+ * @param url Defaults to `window.location.href`.
352
+ */
353
+ async completeSignIn(url) {
354
+ const href = url ?? (typeof window !== "undefined" && window.location ? window.location.href : null);
355
+ if (!href) return null;
356
+ const parsed = new URL(href);
357
+ const code = parsed.searchParams.get("code");
358
+ const type = parsed.searchParams.get("type");
359
+ if (!code || !type || !CONFIRMATION_TYPES.has(type)) return null;
360
+ if (url === void 0 && typeof window !== "undefined" && window.history?.replaceState) {
361
+ parsed.searchParams.delete("code");
362
+ parsed.searchParams.delete("type");
363
+ try {
364
+ window.history.replaceState(null, "", parsed.pathname + parsed.search + parsed.hash);
365
+ } catch {
366
+ }
367
+ }
368
+ const client = await this.getPkceClient();
369
+ const exchanged = await client.auth.exchangeCodeForSession(code);
370
+ const supabaseSession = exchanged?.data?.session;
371
+ if (exchanged?.error || !supabaseSession) return null;
372
+ const result = await this.fetch("/api/widget/auth/magic-link/exchange", {
373
+ method: "POST",
374
+ body: {
375
+ teamId: this.teamSlug,
376
+ accessToken: supabaseSession.access_token,
377
+ refreshToken: supabaseSession.refresh_token
378
+ }
379
+ });
380
+ if (!result.customer || !result.accessToken || !result.refreshToken) return null;
381
+ this.session = {
382
+ customer: result.customer,
383
+ accessToken: result.accessToken,
384
+ refreshToken: result.refreshToken
385
+ };
386
+ return this.session;
387
+ }
388
+ /**
389
+ * Choose a password for the signed-in customer, so later visits can use
390
+ * `login()` instead of an emailed link.
391
+ *
392
+ * Needs a session from `completeSignIn()` or `login()`. This is the same
393
+ * Supabase Auth password update the hosted widget makes after a first
394
+ * email sign-in, and it clears the widget's "choose a password" prompt.
395
+ * The customer stays signed in.
396
+ *
397
+ * @throws CStarValidationError when the password is under 8 characters or is rejected
398
+ * @throws CStarAuthenticationError when there is no session or it has expired
399
+ */
400
+ async setPassword(password) {
401
+ if (!password || password.length < MIN_PASSWORD_LENGTH) {
402
+ throw parsePublicError(
403
+ 400,
404
+ { error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
405
+ "Invalid password"
406
+ );
407
+ }
408
+ const current = this.session;
409
+ if (!current) {
410
+ throw parsePublicError(
411
+ 401,
412
+ { error: "Sign in before choosing a password." },
413
+ "Not signed in"
414
+ );
415
+ }
416
+ const client = await this.getPkceClient();
417
+ const restored = await client.auth.setSession({
418
+ access_token: current.accessToken,
419
+ refresh_token: current.refreshToken
420
+ });
421
+ const restoredSession = restored?.data?.session;
422
+ if (restored?.error || !restoredSession) {
423
+ throw parsePublicError(
424
+ 401,
425
+ { error: "Your session has expired. Sign in again to choose a password." },
426
+ "Session expired"
427
+ );
428
+ }
429
+ if (this.session === current) {
300
430
  this.session = {
301
- customer: response.customer,
302
- accessToken: response.accessToken,
303
- refreshToken: response.refreshToken
431
+ ...current,
432
+ accessToken: restoredSession.access_token,
433
+ refreshToken: restoredSession.refresh_token
304
434
  };
305
435
  }
306
- return response;
436
+ const { error } = await client.auth.updateUser({
437
+ password,
438
+ data: { widget_password_pending: false }
439
+ });
440
+ if (error) {
441
+ if (error.status === 429) {
442
+ throw parsePublicError(
443
+ 429,
444
+ { error: "Too many password changes. Please wait a few minutes and try again." },
445
+ "Too many password changes"
446
+ );
447
+ }
448
+ const rejected = error.status === 400 || error.status === 422;
449
+ throw parsePublicError(
450
+ rejected ? 400 : 502,
451
+ { error: "That password could not be saved. Please try another one." },
452
+ "Password not saved"
453
+ );
454
+ }
307
455
  }
308
456
  /**
309
457
  * Log in an existing customer with email and password.
310
458
  *
459
+ * Signup is password-less, so this works only after the customer has
460
+ * chosen a password with `setPassword()` or in the hosted widget.
461
+ *
311
462
  * On success, stores the session internally so subsequent
312
463
  * ChatClient requests can use `auth.getSession()` for tokens.
313
464
  *
@@ -331,9 +482,18 @@ var AuthClient = class {
331
482
  }
332
483
  return response;
333
484
  }
334
- /** Clear the stored session. */
485
+ /**
486
+ * Clear the session: the in-memory one and, when a sign-in link or
487
+ * setPassword() restored it through Supabase Auth, the copy that client
488
+ * keeps in browser storage, so the next person on the device is signed out.
489
+ */
335
490
  logout() {
336
491
  this.session = null;
492
+ const pkceClient = this.pkceClient;
493
+ if (pkceClient) {
494
+ void pkceClient.then((client) => client.auth.signOut({ scope: "local" })).catch(() => {
495
+ });
496
+ }
337
497
  }
338
498
  /** Get the current session, or null if not authenticated. */
339
499
  getSession() {
@@ -351,6 +511,35 @@ var AuthClient = class {
351
511
  get accessToken() {
352
512
  return this.session?.accessToken ?? null;
353
513
  }
514
+ getPkceClient() {
515
+ const { supabaseUrl, supabaseKey } = this;
516
+ if (!supabaseUrl || !supabaseKey) {
517
+ return Promise.reject(
518
+ parsePublicError(
519
+ 400,
520
+ {
521
+ error: "AuthClient needs supabaseUrl and supabaseKey when baseUrl is not https://www.cstar.help."
522
+ },
523
+ "Missing Supabase config"
524
+ )
525
+ );
526
+ }
527
+ this.pkceClient ?? (this.pkceClient = import(
528
+ /* webpackIgnore: true */
529
+ "@supabase/supabase-js"
530
+ ).then(
531
+ ({ createClient }) => createClient(supabaseUrl, supabaseKey, {
532
+ auth: {
533
+ flowType: "pkce",
534
+ persistSession: true,
535
+ autoRefreshToken: false,
536
+ storageKey: PKCE_STORAGE_KEY,
537
+ detectSessionInUrl: false
538
+ }
539
+ })
540
+ ));
541
+ return this.pkceClient;
542
+ }
354
543
  async fetch(path, options) {
355
544
  const url = `${this.baseUrl}${path}`;
356
545
  const response = await globalThis.fetch(url, {
@@ -475,6 +475,8 @@ var DEFAULT_POLLING_INTERVAL = 3e3;
475
475
  var ChatClient = class {
476
476
  constructor(config) {
477
477
  this.sessionToken = null;
478
+ this.refreshToken = null;
479
+ this.refreshPromise = null;
478
480
  this.teamId = null;
479
481
  this.customerId = null;
480
482
  this.authMode = null;
@@ -567,6 +569,7 @@ var ChatClient = class {
567
569
  this.applySession(
568
570
  {
569
571
  accessToken: response.accessToken,
572
+ refreshToken: response.refreshToken ?? void 0,
570
573
  teamId,
571
574
  customerName: response.customer.name ?? response.customer.email
572
575
  },
@@ -597,6 +600,7 @@ var ChatClient = class {
597
600
  applySession(params, generation) {
598
601
  this.assertSession(generation);
599
602
  this.sessionToken = params.accessToken;
603
+ this.refreshToken = params.refreshToken ?? null;
600
604
  if (params.teamId) {
601
605
  this.teamId = params.teamId;
602
606
  }
@@ -706,6 +710,8 @@ var ChatClient = class {
706
710
  this.pollingSubscriptions.clear();
707
711
  this.realtimeMessageSubscriptions.clear();
708
712
  this.sessionToken = null;
713
+ this.refreshToken = null;
714
+ this.refreshPromise = null;
709
715
  this.teamId = null;
710
716
  this.customerId = null;
711
717
  this.authMode = null;
@@ -737,7 +743,7 @@ var ChatClient = class {
737
743
  * the first authenticated round-trip, `getAuthMode()` returns the
738
744
  * server-confirmed mode.
739
745
  */
740
- async fetch(path, options) {
746
+ async fetch(path, options, retriedAfterRefresh = false) {
741
747
  const generation = this.sessionGeneration;
742
748
  const assertRequest = () => {
743
749
  this.assertSession(generation);
@@ -748,8 +754,10 @@ var ChatClient = class {
748
754
  const headers = {
749
755
  "Content-Type": "application/json"
750
756
  };
751
- if (this.sessionToken) {
752
- headers["Authorization"] = `Bearer ${this.sessionToken}`;
757
+ if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
758
+ const sentToken = this.sessionToken;
759
+ if (sentToken) {
760
+ headers["Authorization"] = `Bearer ${sentToken}`;
753
761
  }
754
762
  let response;
755
763
  try {
@@ -777,11 +785,17 @@ var ChatClient = class {
777
785
  }
778
786
  assertRequest();
779
787
  const requestId = responseHeaders?.get("X-Request-Id") ?? null;
788
+ if (response.status === 401 && sentToken && this.refreshToken && !retriedAfterRefresh) {
789
+ const refreshed = await this.refreshSession(sentToken);
790
+ assertRequest();
791
+ if (refreshed) return this.fetch(path, options, true);
792
+ }
780
793
  if (!response.ok) {
781
- const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
794
+ const errorBody = await response.json().catch(() => null);
782
795
  assertRequest();
783
- const code = errorBody.error?.code ?? "UNKNOWN";
784
- const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
796
+ const structured = errorBody?.error && typeof errorBody.error === "object" ? errorBody.error : null;
797
+ const code = structured?.code ?? "UNKNOWN";
798
+ const message = structured?.message ?? (typeof errorBody?.error === "string" ? errorBody.error : null) ?? (typeof errorBody?.message === "string" ? errorBody.message : null) ?? `Widget API error: ${response.status}`;
785
799
  if (response.status === 401 || response.status === 403) {
786
800
  throw new ChatClientAuthError({
787
801
  message,
@@ -822,9 +836,52 @@ var ChatClient = class {
822
836
  return body;
823
837
  } catch (error) {
824
838
  assertRequest();
839
+ if (options.idempotencyKey)
840
+ throw new ChatClientNetworkError({
841
+ message: "Could not read the message acknowledgment",
842
+ originalError: error
843
+ });
825
844
  throw error;
826
845
  }
827
846
  }
847
+ /**
848
+ * Renew the access token with the stored refresh token. Concurrent 401s
849
+ * share one refresh. Resolves true when a newer token is in place.
850
+ */
851
+ refreshSession(rejectedToken) {
852
+ if (this.sessionToken && this.sessionToken !== rejectedToken) return Promise.resolve(true);
853
+ if (!this.refreshPromise) {
854
+ const pending = this.requestRefresh().finally(() => {
855
+ if (this.refreshPromise === pending) this.refreshPromise = null;
856
+ });
857
+ this.refreshPromise = pending;
858
+ }
859
+ return this.refreshPromise;
860
+ }
861
+ async requestRefresh() {
862
+ const generation = this.sessionGeneration;
863
+ const refreshToken = this.refreshToken;
864
+ if (!refreshToken) return false;
865
+ try {
866
+ const response = await globalThis.fetch(`${this.baseUrl}/api/widget/auth/refresh`, {
867
+ method: "POST",
868
+ headers: { "Content-Type": "application/json" },
869
+ body: JSON.stringify({ refreshToken })
870
+ });
871
+ if (generation !== this.sessionGeneration) return false;
872
+ if (!response.ok) {
873
+ if (response.status === 401) this.refreshToken = null;
874
+ return false;
875
+ }
876
+ const body = await response.json();
877
+ if (generation !== this.sessionGeneration || !body.accessToken) return false;
878
+ this.sessionToken = body.accessToken;
879
+ this.refreshToken = body.refreshToken ?? refreshToken;
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
828
885
  /**
829
886
  * Look up the team UUID from the configured `teamSlug`. Used by
830
887
  * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
@@ -1297,26 +1354,80 @@ var MessagesApi = class {
1297
1354
  assertCurrent();
1298
1355
  return response.data.messages;
1299
1356
  }
1300
- /** Send a message to a ticket. */
1301
- async send(ticketId, content) {
1357
+ /**
1358
+ * Send one logical reply. Network and server failures retry twice with the
1359
+ * same key. A separate send call is a new intent unless a retained key is supplied.
1360
+ */
1361
+ async send(ticketId, content, options = {}) {
1302
1362
  const assertCurrent = this.chat.captureSession();
1363
+ options.signal?.throwIfAborted();
1364
+ const key = options.idempotencyKey ?? createMessageRequestKey();
1365
+ if (!key || key.length > 250 || key.trim() !== key || /[^\x20-\x7e]/.test(key))
1366
+ throw new ChatClientError({
1367
+ code: "INVALID_IDEMPOTENCY_KEY",
1368
+ message: "Idempotency-Key must contain 1 to 250 printable characters, without surrounding whitespace."
1369
+ });
1303
1370
  const teamId = await this.chat.ensureTeamId();
1304
1371
  assertCurrent();
1305
- const response = await this.chat.fetch(
1306
- "/api/widget/messages",
1307
- {
1308
- method: "POST",
1309
- body: {
1310
- teamId,
1311
- ticketId,
1312
- content
1313
- }
1372
+ for (let attempt = 0; ; attempt++) {
1373
+ assertCurrent();
1374
+ options.signal?.throwIfAborted();
1375
+ try {
1376
+ const response = await this.chat.fetch(
1377
+ "/api/widget/messages",
1378
+ {
1379
+ method: "POST",
1380
+ body: { teamId, ticketId, content },
1381
+ idempotencyKey: key,
1382
+ signal: options.signal
1383
+ }
1384
+ );
1385
+ assertCurrent();
1386
+ if (typeof response?.data?.message?.id !== "string" || !response.data.message.id)
1387
+ throw new ChatClientNetworkError({
1388
+ message: "Message acknowledgment was incomplete",
1389
+ originalError: new Error(
1390
+ "The reply may already be saved. Retry with the same send identity."
1391
+ )
1392
+ });
1393
+ return response.data.message;
1394
+ } catch (error) {
1395
+ assertCurrent();
1396
+ options.signal?.throwIfAborted();
1397
+ if (error instanceof ChatClientError) error.idempotencyKey = key;
1398
+ if (attempt >= 2 || !(error instanceof ChatClientNetworkError || error instanceof ChatClientServerError))
1399
+ throw error;
1400
+ await waitForMessageRetry(250 * 2 ** attempt, options.signal);
1314
1401
  }
1315
- );
1316
- assertCurrent();
1317
- return response.data.message;
1402
+ }
1318
1403
  }
1319
1404
  };
1405
+ function createMessageRequestKey() {
1406
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
1407
+ if (typeof globalThis.crypto?.getRandomValues !== "function")
1408
+ throw new ChatClientError({
1409
+ code: "RANDOM_UNAVAILABLE",
1410
+ message: "Secure random values are required to send a message safely."
1411
+ });
1412
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
1413
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1414
+ }
1415
+ function waitForMessageRetry(delay, signal) {
1416
+ signal?.throwIfAborted();
1417
+ return new Promise((resolve, reject) => {
1418
+ const finish = () => {
1419
+ signal?.removeEventListener("abort", abort);
1420
+ resolve();
1421
+ };
1422
+ const timer = setTimeout(finish, delay);
1423
+ const abort = () => {
1424
+ clearTimeout(timer);
1425
+ signal?.removeEventListener("abort", abort);
1426
+ reject(signal?.reason);
1427
+ };
1428
+ signal?.addEventListener("abort", abort, { once: true });
1429
+ });
1430
+ }
1320
1431
  function decodeJwtSub(jwt) {
1321
1432
  try {
1322
1433
  const parts = jwt.split(".");