@cstar.help/js 0.11.0 → 0.12.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.
@@ -12,6 +12,39 @@ interface ChatConfig {
12
12
  /** Polling interval in ms when realtime is disabled or unavailable (defaults to 3000). */
13
13
  pollingInterval?: number;
14
14
  }
15
+ /**
16
+ * Which auth path the client used to identify the customer. Set
17
+ * automatically from the `X-Cstar-Auth-Mode` response header on the first
18
+ * authenticated response — consumers don't declare it. Useful for runtime
19
+ * branching in apps that wrap multiple ChatClient instances.
20
+ */
21
+ type ChatAuthMode = 'hmac' | 'session';
22
+ /**
23
+ * Parameters for `loginWithSession()` — for integrators that already have
24
+ * a Supabase access token from their own login flow.
25
+ */
26
+ interface LoginWithSessionParams {
27
+ /** Supabase access token (JWT). Used as Bearer for every subsequent call. */
28
+ accessToken: string;
29
+ /** Optional: the team UUID. If omitted, derived server-side from the token. */
30
+ teamId?: string;
31
+ /** Optional: a hint for the customer name (UI only — server stays authoritative). */
32
+ customerName?: string;
33
+ }
34
+ /** Parameters for `loginWithCredentials()` — email/password sign-in. */
35
+ interface LoginWithCredentialsParams {
36
+ email: string;
37
+ password: string;
38
+ }
39
+ /** Result of a credentials login. The client retains the access token internally. */
40
+ interface LoginWithCredentialsResult {
41
+ customer: {
42
+ id: string;
43
+ email: string;
44
+ name: string | null;
45
+ };
46
+ teamId: string;
47
+ }
15
48
  /** Parameters for identifying a customer via HMAC signature verification. */
16
49
  interface IdentifyParams {
17
50
  /** Customer ID from your system. */
@@ -188,23 +221,125 @@ interface CommunityFollowResult {
188
221
  following: boolean;
189
222
  }
190
223
 
224
+ /**
225
+ * Typed error classes for the cStar widget chat client.
226
+ *
227
+ * Every error thrown by `ChatClient.fetch()` is one of these subclasses,
228
+ * carrying an HTTP status code, a server-supplied error code, and a
229
+ * `requestId` (from the `X-Request-Id` response header) that customer
230
+ * support can match against server-side logs.
231
+ *
232
+ * Consumer code can branch on instanceof checks instead of string-matching
233
+ * error messages, which is a brittle pattern that breaks the moment the
234
+ * server tweaks its message text:
235
+ *
236
+ * ```typescript
237
+ * try {
238
+ * await chat.identify(customer, signature);
239
+ * } catch (err) {
240
+ * if (err instanceof ChatClientAuthError) {
241
+ * // bad signature, expired token, etc.
242
+ * } else if (err instanceof ChatClientRateLimitError) {
243
+ * // back off and retry after err.retryAfter
244
+ * }
245
+ * }
246
+ * ```
247
+ */
248
+ /** Base class. Thrown by `ChatClient.fetch()` for any non-2xx response. */
249
+ declare class ChatClientError extends Error {
250
+ readonly code: string;
251
+ readonly statusCode: number;
252
+ readonly requestId: string | null;
253
+ constructor(message: string, opts: {
254
+ code?: string;
255
+ statusCode: number;
256
+ requestId?: string | null;
257
+ });
258
+ }
259
+ /** 401 / 403 — auth failed (bad/expired token, wrong team, etc.). */
260
+ declare class ChatClientAuthError extends ChatClientError {
261
+ constructor(opts: {
262
+ message: string;
263
+ code?: string;
264
+ statusCode: number;
265
+ requestId?: string | null;
266
+ });
267
+ }
268
+ /** 429 — too many requests. Inspect `retryAfter` (seconds) before retrying. */
269
+ declare class ChatClientRateLimitError extends ChatClientError {
270
+ readonly retryAfter: number | null;
271
+ constructor(opts: {
272
+ message: string;
273
+ code?: string;
274
+ statusCode: number;
275
+ requestId?: string | null;
276
+ retryAfter?: number | null;
277
+ });
278
+ }
279
+ /** 5xx — the cStar server hit an internal error. Usually transient. */
280
+ declare class ChatClientServerError extends ChatClientError {
281
+ constructor(opts: {
282
+ message: string;
283
+ code?: string;
284
+ statusCode: number;
285
+ requestId?: string | null;
286
+ });
287
+ }
288
+ /**
289
+ * `fetch()` itself failed before a response was received — DNS, TLS,
290
+ * abort, offline, CORS misconfiguration, etc. No statusCode or requestId
291
+ * because the request didn't reach the server. The original cause is
292
+ * preserved on `originalError`.
293
+ */
294
+ declare class ChatClientNetworkError extends ChatClientError {
295
+ readonly originalError: unknown;
296
+ constructor(opts: {
297
+ message: string;
298
+ originalError: unknown;
299
+ });
300
+ }
301
+
191
302
  /**
192
303
  * Customer-facing chat client for the cStar widget API.
193
304
  *
194
- * Real-time messages and typing indicators are enabled by default and
195
- * fall back to polling automatically. No additional configuration is
196
- * required — everything the SDK needs is fetched from the cStar API
197
- * after `identify()`.
305
+ * Supports two auth paths:
306
+ *
307
+ * 1. **HMAC** — `identify(customer, signature)` if you sign customer
308
+ * claims with your team's HMAC secret ("Bring Your Own Auth").
309
+ * 2. **Session** — `loginWithCredentials({ email, password })` or
310
+ * `loginWithSession({ accessToken })` if you use cStar's hosted
311
+ * Supabase auth (or already have a Supabase session from your app).
312
+ *
313
+ * The auth mode is detected automatically from the `X-Cstar-Auth-Mode`
314
+ * response header on the first authenticated call — consumers don't
315
+ * declare it. Real-time messages and typing indicators work in both
316
+ * modes; the SDK opens the right channel based on which login path was
317
+ * used.
318
+ *
319
+ * Real-time is enabled by default and falls back to polling. No extra
320
+ * configuration is required — everything the SDK needs is fetched from
321
+ * the cStar API.
198
322
  *
199
323
  * ```typescript
324
+ * // HMAC mode
200
325
  * const chat = new ChatClient({ teamSlug: 'acme' });
201
326
  * await chat.identify(
202
327
  * { externalId: 'usr_123', email: 'jane@co.com', name: 'Jane', timestamp: Date.now() / 1000 },
203
328
  * 'hmac_signature_hex'
204
329
  * );
330
+ *
331
+ * // Session mode (hosted auth)
332
+ * const chat = new ChatClient({ teamSlug: 'acme' });
333
+ * await chat.loginWithCredentials({ email: 'jane@co.com', password: 'hunter2' });
334
+ *
205
335
  * chat.onMessage('tkt_abc', (msg) => console.log(msg));
206
336
  * chat.onTyping('tkt_abc', ({ agent_name }) => console.log(agent_name, 'is typing'));
207
337
  * ```
338
+ *
339
+ * Errors thrown by `fetch()` (and by the public methods that wrap it)
340
+ * are typed: see `ChatClientAuthError`, `ChatClientRateLimitError`,
341
+ * `ChatClientServerError`, `ChatClientNetworkError`. Each carries
342
+ * `requestId` for correlating with server-side logs.
208
343
  */
209
344
  declare class ChatClient {
210
345
  private readonly baseUrl;
@@ -214,6 +349,7 @@ declare class ChatClient {
214
349
  private sessionToken;
215
350
  private teamId;
216
351
  private customerId;
352
+ private authMode;
217
353
  private pollingTimers;
218
354
  private realtimeManager;
219
355
  private presenceManager;
@@ -238,6 +374,31 @@ declare class ChatClient {
238
374
  * @param testMode - Whether to use test mode (relaxed timestamp validation)
239
375
  */
240
376
  identify(customer: IdentifyParams, signature: string, testMode?: boolean): Promise<IdentifyResult>;
377
+ /**
378
+ * Sign in with cStar's hosted password auth. The SDK keeps the resulting
379
+ * Supabase access token and uses it as Bearer for every subsequent call.
380
+ * Real-time bootstraps automatically (same flow as `identify()`).
381
+ *
382
+ * Use this when your customers don't have a session in your app and you
383
+ * want cStar to handle login. For the case where your app already has a
384
+ * Supabase session, use `loginWithSession({ accessToken })` instead.
385
+ */
386
+ loginWithCredentials(params: LoginWithCredentialsParams): Promise<LoginWithCredentialsResult>;
387
+ /**
388
+ * Use an existing Supabase access token for widget calls. Skip the cStar
389
+ * login flow entirely; the SDK uses the supplied token as Bearer and
390
+ * bootstraps real-time the same way `identify()` does.
391
+ *
392
+ * The widget API verifies the token via the `widget-session` strategy on
393
+ * every request — there is no client-side validation here.
394
+ */
395
+ loginWithSession(params: LoginWithSessionParams): Promise<void>;
396
+ /**
397
+ * Returns the auth mode the client is currently using, or `null` before
398
+ * any authenticated call has resolved one. Useful for branching UI
399
+ * (e.g. to show a "Sign out" button only in session mode).
400
+ */
401
+ getAuthMode(): ChatAuthMode | null;
241
402
  /**
242
403
  * Subscribe to new messages on a ticket.
243
404
  * Uses real-time delivery when available, falls back to polling.
@@ -259,12 +420,33 @@ declare class ChatClient {
259
420
  sendTyping(ticketId: string, isTyping: boolean): Promise<void>;
260
421
  /** Disconnect all subscriptions and clean up. */
261
422
  disconnect(): void;
262
- /** @internal Make an authenticated request to the widget API. */
423
+ /**
424
+ * @internal Make an authenticated request to the widget API.
425
+ *
426
+ * On any non-2xx response, throws a typed subclass of `ChatClientError`
427
+ * (`ChatClientAuthError`, `ChatClientRateLimitError`, etc.) so consumers
428
+ * can branch on error class instead of parsing message strings.
429
+ *
430
+ * On the way out, reads the `X-Cstar-Auth-Mode` response header (set by
431
+ * widgetHandler on every response) and caches it on the client. After
432
+ * the first authenticated round-trip, `getAuthMode()` returns the
433
+ * server-confirmed mode.
434
+ */
263
435
  fetch<T>(path: string, options: {
264
436
  method: string;
265
437
  body?: unknown;
266
438
  query?: Record<string, string | undefined>;
267
439
  }): Promise<T>;
440
+ /**
441
+ * Look up the team UUID from the configured `teamSlug`. Used by
442
+ * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
443
+ * this path — verify-identity accepts the slug directly.
444
+ *
445
+ * @internal
446
+ */
447
+ private resolveTeamIdFromSlug;
448
+ /** @internal Bootstrap realtime from a session login (no precomputed customerId). */
449
+ private initRealtimeFromSession;
268
450
  /** @internal Get the stored team ID for requests that need it. */
269
451
  getTeamId(): string | null;
270
452
  /** @internal Set team ID from identify response or other source. */
@@ -323,4 +505,4 @@ declare class MessagesApi {
323
505
  send(ticketId: string, content: string): Promise<Message>;
324
506
  }
325
507
 
326
- export { ChatClient, type ChatConfig, type IdentifyParams, type IdentifyResult, type Message, type MessageCallback, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
508
+ export { type ChatAuthMode, ChatClient, ChatClientAuthError, ChatClientError, ChatClientNetworkError, ChatClientRateLimitError, ChatClientServerError, type ChatConfig, type IdentifyParams, type IdentifyResult, type LoginWithCredentialsParams, type LoginWithCredentialsResult, type LoginWithSessionParams, type Message, type MessageCallback, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
@@ -98,6 +98,14 @@ var RealtimeManager = class {
98
98
  getClient() {
99
99
  return this.client;
100
100
  }
101
+ /**
102
+ * The current Supabase JWT (set by init/refresh). Returned so callers can
103
+ * decode the `sub` claim to learn the customerId — required after a
104
+ * session-mode login where the SDK doesn't have customerId at hand.
105
+ */
106
+ getCurrentToken() {
107
+ return this.currentToken;
108
+ }
101
109
  /** Whether the realtime manager is initialized and has a token. */
102
110
  get isReady() {
103
111
  return this.client !== null && this.currentToken !== null;
@@ -239,6 +247,43 @@ var PresenceManager = class {
239
247
  }
240
248
  };
241
249
 
250
+ // src/chat/errors.ts
251
+ var ChatClientError = class extends Error {
252
+ constructor(message, opts) {
253
+ super(message);
254
+ this.name = "ChatClientError";
255
+ this.code = opts.code ?? "UNKNOWN";
256
+ this.statusCode = opts.statusCode;
257
+ this.requestId = opts.requestId ?? null;
258
+ }
259
+ };
260
+ var ChatClientAuthError = class extends ChatClientError {
261
+ constructor(opts) {
262
+ super(opts.message, opts);
263
+ this.name = "ChatClientAuthError";
264
+ }
265
+ };
266
+ var ChatClientRateLimitError = class extends ChatClientError {
267
+ constructor(opts) {
268
+ super(opts.message, opts);
269
+ this.name = "ChatClientRateLimitError";
270
+ this.retryAfter = opts.retryAfter ?? null;
271
+ }
272
+ };
273
+ var ChatClientServerError = class extends ChatClientError {
274
+ constructor(opts) {
275
+ super(opts.message, opts);
276
+ this.name = "ChatClientServerError";
277
+ }
278
+ };
279
+ var ChatClientNetworkError = class extends ChatClientError {
280
+ constructor(opts) {
281
+ super(opts.message, { code: "NETWORK_ERROR", statusCode: 0, requestId: null });
282
+ this.name = "ChatClientNetworkError";
283
+ this.originalError = opts.originalError;
284
+ }
285
+ };
286
+
242
287
  // src/chat/index.ts
243
288
  var DEFAULT_BASE_URL = "https://www.cstar.help";
244
289
  var DEFAULT_POLLING_INTERVAL = 3e3;
@@ -247,6 +292,7 @@ var ChatClient = class {
247
292
  this.sessionToken = null;
248
293
  this.teamId = null;
249
294
  this.customerId = null;
295
+ this.authMode = null;
250
296
  this.pollingTimers = /* @__PURE__ */ new Map();
251
297
  this.realtimeManager = null;
252
298
  this.presenceManager = null;
@@ -295,6 +341,70 @@ var ChatClient = class {
295
341
  }
296
342
  return response.data;
297
343
  }
344
+ /**
345
+ * Sign in with cStar's hosted password auth. The SDK keeps the resulting
346
+ * Supabase access token and uses it as Bearer for every subsequent call.
347
+ * Real-time bootstraps automatically (same flow as `identify()`).
348
+ *
349
+ * Use this when your customers don't have a session in your app and you
350
+ * want cStar to handle login. For the case where your app already has a
351
+ * Supabase session, use `loginWithSession({ accessToken })` instead.
352
+ */
353
+ async loginWithCredentials(params) {
354
+ const teamId = await this.resolveTeamIdFromSlug();
355
+ const response = await this.fetch("/api/widget/auth/login", {
356
+ method: "POST",
357
+ body: { email: params.email, password: params.password, teamId }
358
+ });
359
+ if (!response.accessToken) {
360
+ throw new ChatClientAuthError({
361
+ message: "Login succeeded but no session was issued",
362
+ code: "NO_SESSION",
363
+ statusCode: 200,
364
+ requestId: null
365
+ });
366
+ }
367
+ await this.loginWithSession({
368
+ accessToken: response.accessToken,
369
+ teamId,
370
+ customerName: response.customer.name ?? response.customer.email
371
+ });
372
+ return {
373
+ customer: {
374
+ id: this.customerId ?? response.customer.id,
375
+ email: response.customer.email,
376
+ name: response.customer.name
377
+ },
378
+ teamId
379
+ };
380
+ }
381
+ /**
382
+ * Use an existing Supabase access token for widget calls. Skip the cStar
383
+ * login flow entirely; the SDK uses the supplied token as Bearer and
384
+ * bootstraps real-time the same way `identify()` does.
385
+ *
386
+ * The widget API verifies the token via the `widget-session` strategy on
387
+ * every request — there is no client-side validation here.
388
+ */
389
+ async loginWithSession(params) {
390
+ this.sessionToken = params.accessToken;
391
+ if (params.teamId) {
392
+ this.teamId = params.teamId;
393
+ }
394
+ this.authMode = "session";
395
+ if (this.realtimeEnabled) {
396
+ const customerName = params.customerName ?? "";
397
+ this.realtimeInitPromise = this.initRealtimeFromSession(customerName);
398
+ }
399
+ }
400
+ /**
401
+ * Returns the auth mode the client is currently using, or `null` before
402
+ * any authenticated call has resolved one. Useful for branching UI
403
+ * (e.g. to show a "Sign out" button only in session mode).
404
+ */
405
+ getAuthMode() {
406
+ return this.authMode;
407
+ }
298
408
  /**
299
409
  * Subscribe to new messages on a ticket.
300
410
  * Uses real-time delivery when available, falls back to polling.
@@ -348,8 +458,20 @@ var ChatClient = class {
348
458
  this.sessionToken = null;
349
459
  this.teamId = null;
350
460
  this.customerId = null;
461
+ this.authMode = null;
351
462
  }
352
- /** @internal Make an authenticated request to the widget API. */
463
+ /**
464
+ * @internal Make an authenticated request to the widget API.
465
+ *
466
+ * On any non-2xx response, throws a typed subclass of `ChatClientError`
467
+ * (`ChatClientAuthError`, `ChatClientRateLimitError`, etc.) so consumers
468
+ * can branch on error class instead of parsing message strings.
469
+ *
470
+ * On the way out, reads the `X-Cstar-Auth-Mode` response header (set by
471
+ * widgetHandler on every response) and caches it on the client. After
472
+ * the first authenticated round-trip, `getAuthMode()` returns the
473
+ * server-confirmed mode.
474
+ */
353
475
  async fetch(path, options) {
354
476
  const url = this.buildUrl(path, options.query);
355
477
  const headers = {
@@ -358,17 +480,104 @@ var ChatClient = class {
358
480
  if (this.sessionToken) {
359
481
  headers["Authorization"] = `Bearer ${this.sessionToken}`;
360
482
  }
361
- const response = await globalThis.fetch(url, {
362
- method: options.method,
363
- headers,
364
- body: options.body ? JSON.stringify(options.body) : void 0
365
- });
483
+ let response;
484
+ try {
485
+ response = await globalThis.fetch(url, {
486
+ method: options.method,
487
+ headers,
488
+ body: options.body ? JSON.stringify(options.body) : void 0
489
+ });
490
+ } catch (networkErr) {
491
+ throw new ChatClientNetworkError({
492
+ message: networkErr instanceof Error ? networkErr.message : "Network request failed",
493
+ originalError: networkErr
494
+ });
495
+ }
496
+ const responseHeaders = response.headers && typeof response.headers.get === "function" ? response.headers : null;
497
+ const headerMode = responseHeaders?.get("X-Cstar-Auth-Mode") ?? null;
498
+ if (headerMode === "hmac" || headerMode === "session") {
499
+ this.authMode = headerMode;
500
+ }
501
+ const requestId = responseHeaders?.get("X-Request-Id") ?? null;
366
502
  if (!response.ok) {
367
- const errorBody = await response.json().catch(() => ({ error: { message: "Request failed" } }));
368
- throw new Error(errorBody.error?.message ?? `Widget API error: ${response.status}`);
503
+ const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
504
+ const code = errorBody.error?.code ?? "UNKNOWN";
505
+ const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
506
+ if (response.status === 401 || response.status === 403) {
507
+ throw new ChatClientAuthError({
508
+ message,
509
+ code,
510
+ statusCode: response.status,
511
+ requestId
512
+ });
513
+ }
514
+ if (response.status === 429) {
515
+ const retryAfterHeader = responseHeaders?.get("Retry-After") ?? null;
516
+ const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null;
517
+ throw new ChatClientRateLimitError({
518
+ message,
519
+ code,
520
+ statusCode: response.status,
521
+ requestId,
522
+ retryAfter: Number.isFinite(retryAfter ?? NaN) ? retryAfter : null
523
+ });
524
+ }
525
+ if (response.status >= 500) {
526
+ throw new ChatClientServerError({
527
+ message,
528
+ code,
529
+ statusCode: response.status,
530
+ requestId
531
+ });
532
+ }
533
+ throw new ChatClientError(message, {
534
+ code,
535
+ statusCode: response.status,
536
+ requestId
537
+ });
369
538
  }
370
539
  return response.json();
371
540
  }
541
+ /**
542
+ * Look up the team UUID from the configured `teamSlug`. Used by
543
+ * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
544
+ * this path — verify-identity accepts the slug directly.
545
+ *
546
+ * @internal
547
+ */
548
+ async resolveTeamIdFromSlug() {
549
+ const response = await this.fetch(
550
+ "/api/widget/team-by-slug",
551
+ {
552
+ method: "GET",
553
+ query: { teamSlug: this.teamSlug }
554
+ }
555
+ );
556
+ return response.data.teamId;
557
+ }
558
+ /** @internal Bootstrap realtime from a session login (no precomputed customerId). */
559
+ async initRealtimeFromSession(customerName) {
560
+ try {
561
+ this.realtimeManager = new RealtimeManager({
562
+ fetchToken: () => this.fetchRealtimeToken()
563
+ });
564
+ const ready = await this.realtimeManager.init();
565
+ if (!ready) return;
566
+ this.realtimeReady = true;
567
+ const token = this.realtimeManager.getCurrentToken();
568
+ const customerId = token ? decodeJwtSub(token) : null;
569
+ if (customerId) {
570
+ this.customerId = customerId;
571
+ this.presenceManager = new PresenceManager({ customerId, customerName });
572
+ const client = this.realtimeManager.getClient();
573
+ if (client) {
574
+ this.presenceManager.setClient(client);
575
+ }
576
+ }
577
+ } catch {
578
+ this.realtimeReady = false;
579
+ }
580
+ }
372
581
  /** @internal Get the stored team ID for requests that need it. */
373
582
  getTeamId() {
374
583
  return this.teamId;
@@ -664,6 +873,25 @@ var MessagesApi = class {
664
873
  return response.data.message;
665
874
  }
666
875
  };
876
+ function decodeJwtSub(jwt) {
877
+ try {
878
+ const parts = jwt.split(".");
879
+ if (parts.length !== 3) return null;
880
+ const payloadB64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
881
+ const padded = payloadB64 + "=".repeat((4 - payloadB64.length % 4) % 4);
882
+ const payload = JSON.parse(
883
+ typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("utf-8")
884
+ );
885
+ return typeof payload.sub === "string" ? payload.sub : null;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
667
890
  export {
668
- ChatClient
891
+ ChatClient,
892
+ ChatClientAuthError,
893
+ ChatClientError,
894
+ ChatClientNetworkError,
895
+ ChatClientRateLimitError,
896
+ ChatClientServerError
669
897
  };