@cstar.help/js 0.17.5 → 0.18.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.
@@ -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, {