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