@cstar.help/js 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -200,32 +200,84 @@ const { data: searchResults } = await cstar.search.query({
200
200
 
201
201
  Build custom chat experiences in the browser. Uses a publishable key — safe for frontend code.
202
202
 
203
+ The chat client supports two authentication modes:
204
+
205
+ - **HMAC** — your server signs the customer payload with the team's signing secret. Best for sites where users are already authenticated server-side.
206
+ - **Session** — the customer signs in directly with email/password (or magic link). Best for standalone widgets and customer portals where there's no host-app session to bridge.
207
+
208
+ You don't have to pick the mode up-front. The first response from any cstar endpoint includes an `X-Cstar-Auth-Mode` header, which the SDK caches; `chat.getAuthMode()` returns `'hmac'`, `'session'`, or `null` after the first call.
209
+
210
+ ### HMAC
211
+
203
212
  ```typescript
204
213
  import { ChatClient } from '@cstar.help/js/chat';
205
214
 
206
215
  const chat = new ChatClient({ teamSlug: 'acme' });
207
216
 
208
- // Identify the customer (HMAC signature from your server)
209
217
  await chat.identify(
210
218
  { externalId: 'usr_123', email: 'jane@example.com', name: 'Jane', timestamp: Date.now() / 1000 },
211
219
  hmacSignature
212
220
  );
221
+ ```
213
222
 
214
- // List tickets
215
- const { tickets } = await chat.tickets.list();
223
+ ### Session (email/password)
224
+
225
+ ```typescript
226
+ const chat = new ChatClient({ teamSlug: 'acme' });
227
+
228
+ // Login with credentials — returns the customer + tokens
229
+ const { customer, accessToken, refreshToken } = await chat.loginWithCredentials(
230
+ 'jane@example.com',
231
+ 'hunter2'
232
+ );
233
+
234
+ // Or rehydrate from previously stored tokens
235
+ await chat.loginWithSession(accessToken, refreshToken);
236
+ ```
237
+
238
+ ### Once authenticated (either mode)
216
239
 
217
- // Send a message
240
+ ```typescript
241
+ const { tickets } = await chat.tickets.list();
218
242
  await chat.messages.send('tkt_abc123', 'Thanks for the help!');
219
243
 
220
- // Subscribe to new messages
221
244
  const unsubscribe = chat.onMessage('tkt_abc123', (message) => {
222
245
  console.log(`${message.sender_name}: ${message.content}`);
223
246
  });
224
247
 
225
- // Clean up
226
248
  chat.disconnect();
227
249
  ```
228
250
 
251
+ ### Typed errors
252
+
253
+ Every `ChatClient` request throws a typed subclass of `ChatClientError` on failure. Each carries a `requestId` (from the `X-Request-Id` response header) so customer support can correlate against server logs.
254
+
255
+ ```typescript
256
+ import {
257
+ ChatClientError,
258
+ ChatClientAuthError,
259
+ ChatClientRateLimitError,
260
+ ChatClientServerError,
261
+ ChatClientNetworkError
262
+ } from '@cstar.help/js/chat';
263
+
264
+ try {
265
+ await chat.identify(customer, signature);
266
+ } catch (err) {
267
+ if (err instanceof ChatClientAuthError) {
268
+ // 401 / 403 — bad signature, expired token, wrong team
269
+ } else if (err instanceof ChatClientRateLimitError) {
270
+ // 429 — back off for err.retryAfter seconds
271
+ } else if (err instanceof ChatClientServerError) {
272
+ // 5xx — usually transient, retry with backoff
273
+ } else if (err instanceof ChatClientNetworkError) {
274
+ // fetch() itself failed — DNS, offline, CORS — see err.originalError
275
+ } else if (err instanceof ChatClientError) {
276
+ console.log('Error', err.code, err.statusCode, 'requestId:', err.requestId);
277
+ }
278
+ }
279
+ ```
280
+
229
281
  ---
230
282
 
231
283
  ## Client-Side: Public Knowledge Base
@@ -40,22 +40,22 @@ var AuthClient = class {
40
40
  */
41
41
  async signup(params) {
42
42
  const response = await this.fetch(
43
- "/widget/api/auth/signup",
43
+ "/api/widget/auth/signup",
44
44
  {
45
45
  method: "POST",
46
46
  body: {
47
- team_id: this.teamSlug,
47
+ teamId: this.teamSlug,
48
48
  email: params.email,
49
49
  password: params.password,
50
50
  name: params.name
51
51
  }
52
52
  }
53
53
  );
54
- if ("access_token" in response && response.access_token && response.customer) {
54
+ if ("accessToken" in response && response.accessToken && response.customer) {
55
55
  this.session = {
56
56
  customer: response.customer,
57
- accessToken: response.access_token,
58
- refreshToken: response.refresh_token
57
+ accessToken: response.accessToken,
58
+ refreshToken: response.refreshToken
59
59
  };
60
60
  }
61
61
  return response;
@@ -69,19 +69,19 @@ var AuthClient = class {
69
69
  * @throws Error on invalid credentials, unconfirmed email, or server error
70
70
  */
71
71
  async login(params) {
72
- const response = await this.fetch("/widget/api/auth/login", {
72
+ const response = await this.fetch("/api/widget/auth/login", {
73
73
  method: "POST",
74
74
  body: {
75
- team_id: this.teamSlug,
75
+ teamId: this.teamSlug,
76
76
  email: params.email,
77
77
  password: params.password
78
78
  }
79
79
  });
80
- if (response.access_token && response.customer) {
80
+ if (response.accessToken && response.customer) {
81
81
  this.session = {
82
82
  customer: response.customer,
83
- accessToken: response.access_token,
84
- refreshToken: response.refresh_token
83
+ accessToken: response.accessToken,
84
+ refreshToken: response.refreshToken
85
85
  };
86
86
  }
87
87
  return response;
@@ -24,9 +24,9 @@ interface SignupParams {
24
24
  interface SignupResult {
25
25
  customer: AuthCustomer;
26
26
  /** Access token for authenticated requests. Null if email confirmation is required. */
27
- access_token: string | null;
27
+ accessToken: string | null;
28
28
  /** Refresh token for renewing the session. Null if email confirmation is required. */
29
- refresh_token: string | null;
29
+ refreshToken: string | null;
30
30
  }
31
31
  /** Response when email confirmation is required after signup. */
32
32
  interface SignupConfirmationResult {
@@ -45,9 +45,9 @@ interface LoginParams {
45
45
  interface LoginResult {
46
46
  customer: AuthCustomer;
47
47
  /** Access token for authenticated requests. */
48
- access_token: string | null;
48
+ accessToken: string | null;
49
49
  /** Refresh token for renewing the session. */
50
- refresh_token: string | null;
50
+ refreshToken: string | null;
51
51
  }
52
52
  /** Stored session with tokens and customer identity. */
53
53
  interface AuthSession {
@@ -24,9 +24,9 @@ interface SignupParams {
24
24
  interface SignupResult {
25
25
  customer: AuthCustomer;
26
26
  /** Access token for authenticated requests. Null if email confirmation is required. */
27
- access_token: string | null;
27
+ accessToken: string | null;
28
28
  /** Refresh token for renewing the session. Null if email confirmation is required. */
29
- refresh_token: string | null;
29
+ refreshToken: string | null;
30
30
  }
31
31
  /** Response when email confirmation is required after signup. */
32
32
  interface SignupConfirmationResult {
@@ -45,9 +45,9 @@ interface LoginParams {
45
45
  interface LoginResult {
46
46
  customer: AuthCustomer;
47
47
  /** Access token for authenticated requests. */
48
- access_token: string | null;
48
+ accessToken: string | null;
49
49
  /** Refresh token for renewing the session. */
50
- refresh_token: string | null;
50
+ refreshToken: string | null;
51
51
  }
52
52
  /** Stored session with tokens and customer identity. */
53
53
  interface AuthSession {
@@ -16,22 +16,22 @@ var AuthClient = class {
16
16
  */
17
17
  async signup(params) {
18
18
  const response = await this.fetch(
19
- "/widget/api/auth/signup",
19
+ "/api/widget/auth/signup",
20
20
  {
21
21
  method: "POST",
22
22
  body: {
23
- team_id: this.teamSlug,
23
+ teamId: this.teamSlug,
24
24
  email: params.email,
25
25
  password: params.password,
26
26
  name: params.name
27
27
  }
28
28
  }
29
29
  );
30
- if ("access_token" in response && response.access_token && response.customer) {
30
+ if ("accessToken" in response && response.accessToken && response.customer) {
31
31
  this.session = {
32
32
  customer: response.customer,
33
- accessToken: response.access_token,
34
- refreshToken: response.refresh_token
33
+ accessToken: response.accessToken,
34
+ refreshToken: response.refreshToken
35
35
  };
36
36
  }
37
37
  return response;
@@ -45,19 +45,19 @@ var AuthClient = class {
45
45
  * @throws Error on invalid credentials, unconfirmed email, or server error
46
46
  */
47
47
  async login(params) {
48
- const response = await this.fetch("/widget/api/auth/login", {
48
+ const response = await this.fetch("/api/widget/auth/login", {
49
49
  method: "POST",
50
50
  body: {
51
- team_id: this.teamSlug,
51
+ teamId: this.teamSlug,
52
52
  email: params.email,
53
53
  password: params.password
54
54
  }
55
55
  });
56
- if (response.access_token && response.customer) {
56
+ if (response.accessToken && response.customer) {
57
57
  this.session = {
58
58
  customer: response.customer,
59
- accessToken: response.access_token,
60
- refreshToken: response.refresh_token
59
+ accessToken: response.accessToken,
60
+ refreshToken: response.refreshToken
61
61
  };
62
62
  }
63
63
  return response;
@@ -30,7 +30,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/chat/index.ts
31
31
  var chat_exports = {};
32
32
  __export(chat_exports, {
33
- ChatClient: () => ChatClient
33
+ ChatClient: () => ChatClient,
34
+ ChatClientAuthError: () => ChatClientAuthError,
35
+ ChatClientError: () => ChatClientError,
36
+ ChatClientNetworkError: () => ChatClientNetworkError,
37
+ ChatClientRateLimitError: () => ChatClientRateLimitError,
38
+ ChatClientServerError: () => ChatClientServerError
34
39
  });
35
40
  module.exports = __toCommonJS(chat_exports);
36
41
 
@@ -134,6 +139,14 @@ var RealtimeManager = class {
134
139
  getClient() {
135
140
  return this.client;
136
141
  }
142
+ /**
143
+ * The current Supabase JWT (set by init/refresh). Returned so callers can
144
+ * decode the `sub` claim to learn the customerId — required after a
145
+ * session-mode login where the SDK doesn't have customerId at hand.
146
+ */
147
+ getCurrentToken() {
148
+ return this.currentToken;
149
+ }
137
150
  /** Whether the realtime manager is initialized and has a token. */
138
151
  get isReady() {
139
152
  return this.client !== null && this.currentToken !== null;
@@ -275,6 +288,43 @@ var PresenceManager = class {
275
288
  }
276
289
  };
277
290
 
291
+ // src/chat/errors.ts
292
+ var ChatClientError = class extends Error {
293
+ constructor(message, opts) {
294
+ super(message);
295
+ this.name = "ChatClientError";
296
+ this.code = opts.code ?? "UNKNOWN";
297
+ this.statusCode = opts.statusCode;
298
+ this.requestId = opts.requestId ?? null;
299
+ }
300
+ };
301
+ var ChatClientAuthError = class extends ChatClientError {
302
+ constructor(opts) {
303
+ super(opts.message, opts);
304
+ this.name = "ChatClientAuthError";
305
+ }
306
+ };
307
+ var ChatClientRateLimitError = class extends ChatClientError {
308
+ constructor(opts) {
309
+ super(opts.message, opts);
310
+ this.name = "ChatClientRateLimitError";
311
+ this.retryAfter = opts.retryAfter ?? null;
312
+ }
313
+ };
314
+ var ChatClientServerError = class extends ChatClientError {
315
+ constructor(opts) {
316
+ super(opts.message, opts);
317
+ this.name = "ChatClientServerError";
318
+ }
319
+ };
320
+ var ChatClientNetworkError = class extends ChatClientError {
321
+ constructor(opts) {
322
+ super(opts.message, { code: "NETWORK_ERROR", statusCode: 0, requestId: null });
323
+ this.name = "ChatClientNetworkError";
324
+ this.originalError = opts.originalError;
325
+ }
326
+ };
327
+
278
328
  // src/chat/index.ts
279
329
  var DEFAULT_BASE_URL = "https://www.cstar.help";
280
330
  var DEFAULT_POLLING_INTERVAL = 3e3;
@@ -283,6 +333,7 @@ var ChatClient = class {
283
333
  this.sessionToken = null;
284
334
  this.teamId = null;
285
335
  this.customerId = null;
336
+ this.authMode = null;
286
337
  this.pollingTimers = /* @__PURE__ */ new Map();
287
338
  this.realtimeManager = null;
288
339
  this.presenceManager = null;
@@ -331,6 +382,70 @@ var ChatClient = class {
331
382
  }
332
383
  return response.data;
333
384
  }
385
+ /**
386
+ * Sign in with cStar's hosted password auth. The SDK keeps the resulting
387
+ * Supabase access token and uses it as Bearer for every subsequent call.
388
+ * Real-time bootstraps automatically (same flow as `identify()`).
389
+ *
390
+ * Use this when your customers don't have a session in your app and you
391
+ * want cStar to handle login. For the case where your app already has a
392
+ * Supabase session, use `loginWithSession({ accessToken })` instead.
393
+ */
394
+ async loginWithCredentials(params) {
395
+ const teamId = await this.resolveTeamIdFromSlug();
396
+ const response = await this.fetch("/api/widget/auth/login", {
397
+ method: "POST",
398
+ body: { email: params.email, password: params.password, teamId }
399
+ });
400
+ if (!response.accessToken) {
401
+ throw new ChatClientAuthError({
402
+ message: "Login succeeded but no session was issued",
403
+ code: "NO_SESSION",
404
+ statusCode: 200,
405
+ requestId: null
406
+ });
407
+ }
408
+ await this.loginWithSession({
409
+ accessToken: response.accessToken,
410
+ teamId,
411
+ customerName: response.customer.name ?? response.customer.email
412
+ });
413
+ return {
414
+ customer: {
415
+ id: this.customerId ?? response.customer.id,
416
+ email: response.customer.email,
417
+ name: response.customer.name
418
+ },
419
+ teamId
420
+ };
421
+ }
422
+ /**
423
+ * Use an existing Supabase access token for widget calls. Skip the cStar
424
+ * login flow entirely; the SDK uses the supplied token as Bearer and
425
+ * bootstraps real-time the same way `identify()` does.
426
+ *
427
+ * The widget API verifies the token via the `widget-session` strategy on
428
+ * every request — there is no client-side validation here.
429
+ */
430
+ async loginWithSession(params) {
431
+ this.sessionToken = params.accessToken;
432
+ if (params.teamId) {
433
+ this.teamId = params.teamId;
434
+ }
435
+ this.authMode = "session";
436
+ if (this.realtimeEnabled) {
437
+ const customerName = params.customerName ?? "";
438
+ this.realtimeInitPromise = this.initRealtimeFromSession(customerName);
439
+ }
440
+ }
441
+ /**
442
+ * Returns the auth mode the client is currently using, or `null` before
443
+ * any authenticated call has resolved one. Useful for branching UI
444
+ * (e.g. to show a "Sign out" button only in session mode).
445
+ */
446
+ getAuthMode() {
447
+ return this.authMode;
448
+ }
334
449
  /**
335
450
  * Subscribe to new messages on a ticket.
336
451
  * Uses real-time delivery when available, falls back to polling.
@@ -384,8 +499,20 @@ var ChatClient = class {
384
499
  this.sessionToken = null;
385
500
  this.teamId = null;
386
501
  this.customerId = null;
502
+ this.authMode = null;
387
503
  }
388
- /** @internal Make an authenticated request to the widget API. */
504
+ /**
505
+ * @internal Make an authenticated request to the widget API.
506
+ *
507
+ * On any non-2xx response, throws a typed subclass of `ChatClientError`
508
+ * (`ChatClientAuthError`, `ChatClientRateLimitError`, etc.) so consumers
509
+ * can branch on error class instead of parsing message strings.
510
+ *
511
+ * On the way out, reads the `X-Cstar-Auth-Mode` response header (set by
512
+ * widgetHandler on every response) and caches it on the client. After
513
+ * the first authenticated round-trip, `getAuthMode()` returns the
514
+ * server-confirmed mode.
515
+ */
389
516
  async fetch(path, options) {
390
517
  const url = this.buildUrl(path, options.query);
391
518
  const headers = {
@@ -394,17 +521,104 @@ var ChatClient = class {
394
521
  if (this.sessionToken) {
395
522
  headers["Authorization"] = `Bearer ${this.sessionToken}`;
396
523
  }
397
- const response = await globalThis.fetch(url, {
398
- method: options.method,
399
- headers,
400
- body: options.body ? JSON.stringify(options.body) : void 0
401
- });
524
+ let response;
525
+ try {
526
+ response = await globalThis.fetch(url, {
527
+ method: options.method,
528
+ headers,
529
+ body: options.body ? JSON.stringify(options.body) : void 0
530
+ });
531
+ } catch (networkErr) {
532
+ throw new ChatClientNetworkError({
533
+ message: networkErr instanceof Error ? networkErr.message : "Network request failed",
534
+ originalError: networkErr
535
+ });
536
+ }
537
+ const responseHeaders = response.headers && typeof response.headers.get === "function" ? response.headers : null;
538
+ const headerMode = responseHeaders?.get("X-Cstar-Auth-Mode") ?? null;
539
+ if (headerMode === "hmac" || headerMode === "session") {
540
+ this.authMode = headerMode;
541
+ }
542
+ const requestId = responseHeaders?.get("X-Request-Id") ?? null;
402
543
  if (!response.ok) {
403
- const errorBody = await response.json().catch(() => ({ error: { message: "Request failed" } }));
404
- throw new Error(errorBody.error?.message ?? `Widget API error: ${response.status}`);
544
+ const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
545
+ const code = errorBody.error?.code ?? "UNKNOWN";
546
+ const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
547
+ if (response.status === 401 || response.status === 403) {
548
+ throw new ChatClientAuthError({
549
+ message,
550
+ code,
551
+ statusCode: response.status,
552
+ requestId
553
+ });
554
+ }
555
+ if (response.status === 429) {
556
+ const retryAfterHeader = responseHeaders?.get("Retry-After") ?? null;
557
+ const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null;
558
+ throw new ChatClientRateLimitError({
559
+ message,
560
+ code,
561
+ statusCode: response.status,
562
+ requestId,
563
+ retryAfter: Number.isFinite(retryAfter ?? NaN) ? retryAfter : null
564
+ });
565
+ }
566
+ if (response.status >= 500) {
567
+ throw new ChatClientServerError({
568
+ message,
569
+ code,
570
+ statusCode: response.status,
571
+ requestId
572
+ });
573
+ }
574
+ throw new ChatClientError(message, {
575
+ code,
576
+ statusCode: response.status,
577
+ requestId
578
+ });
405
579
  }
406
580
  return response.json();
407
581
  }
582
+ /**
583
+ * Look up the team UUID from the configured `teamSlug`. Used by
584
+ * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
585
+ * this path — verify-identity accepts the slug directly.
586
+ *
587
+ * @internal
588
+ */
589
+ async resolveTeamIdFromSlug() {
590
+ const response = await this.fetch(
591
+ "/api/widget/team-by-slug",
592
+ {
593
+ method: "GET",
594
+ query: { teamSlug: this.teamSlug }
595
+ }
596
+ );
597
+ return response.data.teamId;
598
+ }
599
+ /** @internal Bootstrap realtime from a session login (no precomputed customerId). */
600
+ async initRealtimeFromSession(customerName) {
601
+ try {
602
+ this.realtimeManager = new RealtimeManager({
603
+ fetchToken: () => this.fetchRealtimeToken()
604
+ });
605
+ const ready = await this.realtimeManager.init();
606
+ if (!ready) return;
607
+ this.realtimeReady = true;
608
+ const token = this.realtimeManager.getCurrentToken();
609
+ const customerId = token ? decodeJwtSub(token) : null;
610
+ if (customerId) {
611
+ this.customerId = customerId;
612
+ this.presenceManager = new PresenceManager({ customerId, customerName });
613
+ const client = this.realtimeManager.getClient();
614
+ if (client) {
615
+ this.presenceManager.setClient(client);
616
+ }
617
+ }
618
+ } catch {
619
+ this.realtimeReady = false;
620
+ }
621
+ }
408
622
  /** @internal Get the stored team ID for requests that need it. */
409
623
  getTeamId() {
410
624
  return this.teamId;
@@ -700,7 +914,26 @@ var MessagesApi = class {
700
914
  return response.data.message;
701
915
  }
702
916
  };
917
+ function decodeJwtSub(jwt) {
918
+ try {
919
+ const parts = jwt.split(".");
920
+ if (parts.length !== 3) return null;
921
+ const payloadB64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
922
+ const padded = payloadB64 + "=".repeat((4 - payloadB64.length % 4) % 4);
923
+ const payload = JSON.parse(
924
+ typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("utf-8")
925
+ );
926
+ return typeof payload.sub === "string" ? payload.sub : null;
927
+ } catch {
928
+ return null;
929
+ }
930
+ }
703
931
  // Annotate the CommonJS export names for ESM import in node:
704
932
  0 && (module.exports = {
705
- ChatClient
933
+ ChatClient,
934
+ ChatClientAuthError,
935
+ ChatClientError,
936
+ ChatClientNetworkError,
937
+ ChatClientRateLimitError,
938
+ ChatClientServerError
706
939
  });