@snackbase/sdk 0.2.0 → 0.3.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.
package/dist/index.cjs CHANGED
@@ -38,8 +38,11 @@ var SnackBaseError = class SnackBaseError extends Error {
38
38
  details;
39
39
  field;
40
40
  retryable;
41
- constructor(message, code, status, details, retryable = false, field) {
41
+ constructor(message, code, status, details, retryable = false, field, redirectUrl, authProvider, providerName) {
42
42
  super(message);
43
+ this.redirectUrl = redirectUrl;
44
+ this.authProvider = authProvider;
45
+ this.providerName = providerName;
43
46
  this.name = this.constructor.name;
44
47
  this.code = code;
45
48
  this.status = status;
@@ -68,6 +71,30 @@ var AuthorizationError = class AuthorizationError extends SnackBaseError {
68
71
  }
69
72
  };
70
73
  /**
74
+ * Thrown when an API key is restricted to superadmin users (403).
75
+ */
76
+ var ApiKeyRestrictedError = class ApiKeyRestrictedError extends SnackBaseError {
77
+ constructor(message, details) {
78
+ super(message || "API keys are restricted to superadmin users. Please use JWT authentication.", "API_KEY_RESTRICTED", 403, details || {
79
+ suggestion: "Remove apiKey config and use login() instead",
80
+ documentation: "https://docs.snackbase.com/authentication/api-keys"
81
+ }, false);
82
+ Object.setPrototypeOf(this, ApiKeyRestrictedError.prototype);
83
+ }
84
+ };
85
+ /**
86
+ * Thrown when email verification is required (401).
87
+ */
88
+ var EmailVerificationRequiredError = class EmailVerificationRequiredError extends SnackBaseError {
89
+ constructor(message, details) {
90
+ super(message || "Please check your email inbox to verify your account before logging in.", "EMAIL_VERIFICATION_REQUIRED", 401, details || {
91
+ suggestion: "Click the verification link sent to your email address",
92
+ canResend: true
93
+ }, false);
94
+ Object.setPrototypeOf(this, EmailVerificationRequiredError.prototype);
95
+ }
96
+ };
97
+ /**
71
98
  * Thrown when a resource is not found (404).
72
99
  */
73
100
  var NotFoundError = class NotFoundError extends SnackBaseError {
@@ -391,7 +418,9 @@ const contentTypeInterceptor = (request) => {
391
418
  const createAuthInterceptor = (getToken, apiKey) => {
392
419
  return (request) => {
393
420
  const isUserSpecific = request.url.includes("/auth/oauth/") || request.url.includes("/auth/saml/");
394
- if (apiKey && !isUserSpecific) request.headers["X-API-Key"] = apiKey;
421
+ if (apiKey && !isUserSpecific) {
422
+ if (!(request.url.includes("/auth/login") || request.url.includes("/auth/register"))) request.headers["X-API-Key"] = apiKey;
423
+ }
395
424
  const token = getToken();
396
425
  if (token) request.headers["Authorization"] = `Bearer ${token}`;
397
426
  return request;
@@ -435,6 +464,51 @@ function createErrorFromResponse(response) {
435
464
  return new SnackBaseError(message, "UNKNOWN_ERROR", status, data, false);
436
465
  }
437
466
  }
467
+ /**
468
+ * Enhanced error interceptor to handle 403 API key errors and preserve redirects.
469
+ */
470
+ const createAuthErrorInterceptor = (onAuthError) => {
471
+ return (error) => {
472
+ if (error.status === 403 && error.details) {
473
+ const detail = error.details.detail || error.details.message || "";
474
+ if (detail.includes("superadmin") || detail.includes("restricted")) {
475
+ const restrictedError = new ApiKeyRestrictedError(detail, error.details);
476
+ if (onAuthError) onAuthError(restrictedError);
477
+ throw restrictedError;
478
+ }
479
+ }
480
+ if (error.status === 403 && error.details?.redirect_url) {
481
+ error.redirectUrl = error.details.redirect_url;
482
+ error.authProvider = error.details.auth_provider;
483
+ error.providerName = error.details.provider_name;
484
+ }
485
+ if (error.status === 401 && error.details) {
486
+ const detail = error.details.detail || error.details.message || "";
487
+ if (detail.includes("verify") || detail.includes("email")) {
488
+ const verificationError = new EmailVerificationRequiredError(detail, error.details);
489
+ if (onAuthError) onAuthError(verificationError);
490
+ throw verificationError;
491
+ }
492
+ }
493
+ if (error.status === 401 || error.status === 403) {
494
+ if (onAuthError) onAuthError(error);
495
+ }
496
+ throw error;
497
+ };
498
+ };
499
+
500
+ //#endregion
501
+ //#region src/types/auth.ts
502
+ /**
503
+ * Token type enum matching backend TokenType
504
+ */
505
+ let TokenType = /* @__PURE__ */ function(TokenType) {
506
+ TokenType["JWT"] = "jwt";
507
+ TokenType["API_KEY"] = "api_key";
508
+ TokenType["PERSONAL_TOKEN"] = "personal_token";
509
+ TokenType["OAUTH"] = "oauth";
510
+ return TokenType;
511
+ }({});
438
512
 
439
513
  //#endregion
440
514
  //#region src/core/events.ts
@@ -455,6 +529,74 @@ var AuthEventEmitter = class {
455
529
  }
456
530
  };
457
531
 
532
+ //#endregion
533
+ //#region src/core/constants.ts
534
+ /**
535
+ * System account ID for superadmin detection
536
+ * Nil UUID format used by backend
537
+ * @see https://github.com/snackbase/snackbase/blob/main/src/snackbase/infrastructure/api/middleware/authorization.py
538
+ */
539
+ const SYSTEM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000000";
540
+ /**
541
+ * Token type prefixes matching backend TokenCodec
542
+ */
543
+ const TOKEN_PREFIXES = {
544
+ JWT: "sb_jwt",
545
+ API_KEY: "sb_ak",
546
+ PERSONAL_TOKEN: "sb_pt",
547
+ OAUTH: "sb_ot"
548
+ };
549
+ /**
550
+ * Valid token prefixes for validation
551
+ */
552
+ const VALID_TOKEN_PREFIXES = new Set(Object.values(TOKEN_PREFIXES));
553
+ /**
554
+ * API key endpoint path
555
+ */
556
+ const API_KEY_BASE_PATH = "/api/v1/admin/api-keys";
557
+
558
+ //#endregion
559
+ //#region src/utils/token-utils.ts
560
+ /**
561
+ * Detect token type from token string
562
+ * @param token - The token to analyze
563
+ * @returns Detected token type or undefined
564
+ */
565
+ function detectTokenType(token) {
566
+ if (!token) return void 0;
567
+ switch (token.split(".")[0]) {
568
+ case TOKEN_PREFIXES.API_KEY: return TokenType.API_KEY;
569
+ case TOKEN_PREFIXES.PERSONAL_TOKEN: return TokenType.PERSONAL_TOKEN;
570
+ case TOKEN_PREFIXES.OAUTH: return TokenType.OAUTH;
571
+ case TOKEN_PREFIXES.JWT: return TokenType.JWT;
572
+ default: return token.startsWith("ey") ? TokenType.JWT : void 0;
573
+ }
574
+ }
575
+ /**
576
+ * Check if user is a superadmin
577
+ * @param user - User object to check
578
+ * @returns true if user is superadmin
579
+ */
580
+ function isSuperadmin(user) {
581
+ if (!user) return false;
582
+ return user.account_id === SYSTEM_ACCOUNT_ID;
583
+ }
584
+ /**
585
+ * Format masked API key for display
586
+ * @param key - The full or masked key
587
+ * @returns Formatted masked key
588
+ */
589
+ function formatMaskedKey(key) {
590
+ if (!key) return "";
591
+ if (key.includes("...")) return key;
592
+ const parts = key.split(".");
593
+ if (parts.length === 3) {
594
+ const [, payload, signature] = parts;
595
+ return `${parts[0]}.${payload.slice(0, 4)}...${signature.slice(-4)}`;
596
+ }
597
+ return key;
598
+ }
599
+
458
600
  //#endregion
459
601
  //#region src/core/auth.ts
460
602
  const DEFAULT_AUTH_STATE = {
@@ -463,7 +605,8 @@ const DEFAULT_AUTH_STATE = {
463
605
  token: null,
464
606
  refreshToken: null,
465
607
  isAuthenticated: false,
466
- expiresAt: null
608
+ expiresAt: null,
609
+ tokenType: TokenType.JWT
467
610
  };
468
611
  var AuthManager = class {
469
612
  state = { ...DEFAULT_AUTH_STATE };
@@ -497,6 +640,76 @@ var AuthManager = class {
497
640
  get isAuthenticated() {
498
641
  return this.state.isAuthenticated;
499
642
  }
643
+ get tokenType() {
644
+ return this.state.tokenType;
645
+ }
646
+ /**
647
+ * Update auth state (enhanced to extract token_type)
648
+ */
649
+ async updateState(data) {
650
+ const user = data.user || (data.user_id ? {
651
+ id: data.user_id,
652
+ email: data.email || "",
653
+ role: data.role || "user",
654
+ account_id: data.account_id || "",
655
+ groups: [],
656
+ is_active: true,
657
+ created_at: "",
658
+ last_login: null,
659
+ token_type: TokenType.JWT
660
+ } : null);
661
+ const token = data.token || null;
662
+ const refreshToken = data.refresh_token || data.refreshToken || null;
663
+ let tokenType = TokenType.JWT;
664
+ if (token) tokenType = detectTokenType(token) || TokenType.JWT;
665
+ else if (user?.token_type) tokenType = user.token_type;
666
+ this.state = {
667
+ ...this.state,
668
+ user,
669
+ account: data.account || (data.account_id ? {
670
+ id: data.account_id,
671
+ slug: "",
672
+ name: "",
673
+ created_at: ""
674
+ } : this.state.account),
675
+ token: token || this.state.token,
676
+ refreshToken: refreshToken || this.state.refreshToken,
677
+ isAuthenticated: !!((token || this.state.token) && user),
678
+ expiresAt: this.calculateExpiry(data) || this.state.expiresAt,
679
+ tokenType
680
+ };
681
+ await this.persist();
682
+ if (token || user) this.events.emit("auth:login", this.state);
683
+ }
684
+ /**
685
+ * Check if current user is superadmin
686
+ */
687
+ isSuperadmin() {
688
+ return isSuperadmin(this.state.user);
689
+ }
690
+ /**
691
+ * Check if current session uses API key authentication
692
+ */
693
+ isApiKeySession() {
694
+ return this.state.tokenType === TokenType.API_KEY;
695
+ }
696
+ /**
697
+ * Check if current session uses personal token authentication
698
+ */
699
+ isPersonalTokenSession() {
700
+ return this.state.tokenType === TokenType.PERSONAL_TOKEN;
701
+ }
702
+ /**
703
+ * Check if current session uses OAuth authentication
704
+ */
705
+ isOAuthSession() {
706
+ return this.state.tokenType === TokenType.OAUTH;
707
+ }
708
+ calculateExpiry(data) {
709
+ if (data.expiresAt) return data.expiresAt;
710
+ if (data.expires_in) return new Date(Date.now() + data.expires_in * 1e3).toISOString();
711
+ return null;
712
+ }
500
713
  async setState(newState) {
501
714
  this.state = {
502
715
  ...this.state,
@@ -571,21 +784,8 @@ var AuthService = class {
571
784
  if (!data.account && this.defaultAccount) data.account = this.defaultAccount;
572
785
  const authData = (await this.http.post("/api/v1/auth/login", data)).data;
573
786
  console.log("Login Response:", JSON.stringify(authData, null, 2));
574
- const refreshToken = authData.refresh_token || authData.refreshToken;
575
- let expiresAt = authData.expiresAt;
576
- if (authData.expires_in && !expiresAt) expiresAt = new Date(Date.now() + authData.expires_in * 1e3).toISOString();
577
- await this.auth.setState({
578
- user: authData.user || null,
579
- account: authData.account || null,
580
- token: authData.token || null,
581
- refreshToken: refreshToken || null,
582
- expiresAt: expiresAt || null
583
- });
584
- return {
585
- ...authData,
586
- refreshToken,
587
- expiresAt
588
- };
787
+ await this.auth.updateState(authData);
788
+ return authData;
589
789
  }
590
790
  /**
591
791
  * Register a new user and account.
@@ -602,19 +802,8 @@ var AuthService = class {
602
802
  const refreshToken = this.auth.refreshToken;
603
803
  if (!refreshToken) throw new Error("No refresh token available");
604
804
  const authData = (await this.http.post("/api/v1/auth/refresh", { refresh_token: refreshToken })).data;
605
- const newRefreshToken = authData.refresh_token || authData.refreshToken;
606
- let expiresAt = authData.expiresAt;
607
- if (authData.expires_in && !expiresAt) expiresAt = new Date(Date.now() + authData.expires_in * 1e3).toISOString();
608
- await this.auth.setState({
609
- token: authData.token || null,
610
- refreshToken: newRefreshToken || null,
611
- expiresAt: expiresAt || null
612
- });
613
- return {
614
- ...authData,
615
- refreshToken: newRefreshToken,
616
- expiresAt
617
- };
805
+ await this.auth.updateState(authData);
806
+ return authData;
618
807
  }
619
808
  /**
620
809
  * Log out the current user.
@@ -633,30 +822,8 @@ var AuthService = class {
633
822
  async getCurrentUser() {
634
823
  const authData = (await this.http.get("/api/v1/auth/me")).data;
635
824
  console.log("GetMe Response:", JSON.stringify(authData, null, 2));
636
- const user = authData.user || (authData.user_id ? {
637
- id: authData.user_id,
638
- email: authData.email || "",
639
- role: authData.role || "user",
640
- groups: [],
641
- is_active: true,
642
- created_at: "",
643
- last_login: null
644
- } : null);
645
- const account = authData.account || (authData.account_id ? {
646
- id: authData.account_id,
647
- slug: "",
648
- name: "",
649
- created_at: ""
650
- } : null);
651
- await this.auth.setState({
652
- user,
653
- account
654
- });
655
- return {
656
- ...authData,
657
- user: user || void 0,
658
- account: account || void 0
659
- };
825
+ await this.auth.updateState(authData);
826
+ return authData;
660
827
  }
661
828
  /**
662
829
  * Initiate password reset flow.
@@ -729,13 +896,7 @@ var AuthService = class {
729
896
  redirectUri,
730
897
  state
731
898
  })).data;
732
- await this.auth.setState({
733
- user: authData.user,
734
- account: authData.account,
735
- token: authData.token,
736
- refreshToken: authData.refreshToken,
737
- expiresAt: authData.expiresAt
738
- });
899
+ await this.auth.updateState(authData);
739
900
  return authData;
740
901
  }
741
902
  /**
@@ -754,13 +915,7 @@ var AuthService = class {
754
915
  */
755
916
  async handleSAMLCallback(params) {
756
917
  const authData = (await this.http.post("/api/v1/auth/saml/acs", params)).data;
757
- await this.auth.setState({
758
- user: authData.user,
759
- account: authData.account,
760
- token: authData.token,
761
- refreshToken: authData.refreshToken,
762
- expiresAt: authData.expiresAt
763
- });
918
+ await this.auth.updateState(authData);
764
919
  return authData;
765
920
  }
766
921
  /**
@@ -1374,33 +1529,34 @@ var ApiKeyService = class {
1374
1529
  this.http = http;
1375
1530
  }
1376
1531
  /**
1377
- * List all API keys for the current user.
1378
- * Keys are masked except for the last 4 characters.
1532
+ * List all API keys
1533
+ * GET /api/v1/admin/api-keys
1379
1534
  */
1380
- async list() {
1381
- return (await this.http.get("/api/v1/admin/api-keys")).data;
1535
+ async list(params) {
1536
+ return (await this.http.get(API_KEY_BASE_PATH, { params })).data;
1382
1537
  }
1383
1538
  /**
1384
- * Get details for a specific API key.
1385
- * The key itself is masked.
1539
+ * Get specific API key
1540
+ * GET /api/v1/admin/api-keys/{id}
1386
1541
  */
1387
1542
  async get(keyId) {
1388
- return (await this.http.get(`/api/v1/admin/api-keys/${keyId}`)).data;
1543
+ return (await this.http.get(`${API_KEY_BASE_PATH}/${encodeURIComponent(keyId)}`)).data;
1389
1544
  }
1390
1545
  /**
1391
- * Create a new API key.
1392
- * The response includes the full key, which is shown only once.
1546
+ * Create a new API key
1547
+ * POST /api/v1/admin/api-keys
1393
1548
  */
1394
1549
  async create(data) {
1395
- return (await this.http.post("/api/v1/admin/api-keys", data)).data;
1550
+ const apiKey = (await this.http.post(API_KEY_BASE_PATH, data)).data;
1551
+ if (apiKey.key && !apiKey.masked_key) apiKey.masked_key = formatMaskedKey(apiKey.key);
1552
+ return apiKey;
1396
1553
  }
1397
1554
  /**
1398
- * Revoke an existing API key.
1399
- * Once revoked, the key can no longer be used.
1555
+ * Revoke an API key
1556
+ * DELETE /api/v1/admin/api-keys/{id}
1400
1557
  */
1401
1558
  async revoke(keyId) {
1402
- await this.http.delete(`/api/v1/admin/api-keys/${keyId}`);
1403
- return { success: true };
1559
+ return (await this.http.delete(`${API_KEY_BASE_PATH}/${encodeURIComponent(keyId)}`)).data;
1404
1560
  }
1405
1561
  };
1406
1562
 
@@ -2394,6 +2550,7 @@ var SnackBaseClient = class {
2394
2550
  this.http.addRequestInterceptor(contentTypeInterceptor);
2395
2551
  this.http.addRequestInterceptor(createAuthInterceptor(() => this.authManager.token || void 0, this.config.apiKey));
2396
2552
  this.http.addResponseInterceptor(errorNormalizationInterceptor);
2553
+ this.http.addErrorInterceptor(createAuthErrorInterceptor(this.config.onAuthError));
2397
2554
  this.http.addErrorInterceptor(errorInterceptor);
2398
2555
  }
2399
2556
  /**
@@ -2415,6 +2572,36 @@ var SnackBaseClient = class {
2415
2572
  return this.authManager.isAuthenticated;
2416
2573
  }
2417
2574
  /**
2575
+ * Check if current user is superadmin.
2576
+ */
2577
+ get isSuperadmin() {
2578
+ return this.authManager.isSuperadmin();
2579
+ }
2580
+ /**
2581
+ * Check if current session uses API key authentication.
2582
+ */
2583
+ get isApiKeySession() {
2584
+ return this.authManager.isApiKeySession();
2585
+ }
2586
+ /**
2587
+ * Check if current session uses personal token authentication.
2588
+ */
2589
+ get isPersonalTokenSession() {
2590
+ return this.authManager.isPersonalTokenSession();
2591
+ }
2592
+ /**
2593
+ * Check if current session uses OAuth authentication.
2594
+ */
2595
+ get isOAuthSession() {
2596
+ return this.authManager.isOAuthSession();
2597
+ }
2598
+ /**
2599
+ * Returns the current token type.
2600
+ */
2601
+ get tokenType() {
2602
+ return this.authManager.tokenType;
2603
+ }
2604
+ /**
2418
2605
  * Access to authentication methods.
2419
2606
  */
2420
2607
  get auth() {
@@ -2629,7 +2816,20 @@ var SnackBaseClient = class {
2629
2816
  };
2630
2817
 
2631
2818
  //#endregion
2819
+ exports.ApiKeyRestrictedError = ApiKeyRestrictedError;
2820
+ exports.AuthenticationError = AuthenticationError;
2821
+ exports.AuthorizationError = AuthorizationError;
2822
+ exports.ConflictError = ConflictError;
2632
2823
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
2824
+ exports.EmailVerificationRequiredError = EmailVerificationRequiredError;
2825
+ exports.NetworkError = NetworkError;
2826
+ exports.NotFoundError = NotFoundError;
2633
2827
  exports.QueryBuilder = QueryBuilder;
2828
+ exports.RateLimitError = RateLimitError;
2829
+ exports.ServerError = ServerError;
2634
2830
  exports.SnackBaseClient = SnackBaseClient;
2831
+ exports.SnackBaseError = SnackBaseError;
2832
+ exports.TimeoutError = TimeoutError;
2833
+ exports.TokenType = TokenType;
2834
+ exports.ValidationError = ValidationError;
2635
2835
  exports.getAutoDetectedStorage = getAutoDetectedStorage;