@stackbe/sdk 0.2.0 → 0.4.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/README.md +311 -77
- package/dist/index.d.mts +203 -19
- package/dist/index.d.ts +203 -19
- package/dist/index.js +218 -26
- package/dist/index.mjs +218 -26
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -39,9 +39,62 @@ var StackBEError = class extends Error {
|
|
|
39
39
|
this.statusCode = statusCode;
|
|
40
40
|
this.code = code;
|
|
41
41
|
}
|
|
42
|
+
/** Check if this is a specific error type */
|
|
43
|
+
is(code) {
|
|
44
|
+
return this.code === code;
|
|
45
|
+
}
|
|
46
|
+
/** Check if this is an auth-related error */
|
|
47
|
+
isAuthError() {
|
|
48
|
+
return [
|
|
49
|
+
"TOKEN_EXPIRED",
|
|
50
|
+
"TOKEN_ALREADY_USED",
|
|
51
|
+
"TOKEN_INVALID",
|
|
52
|
+
"SESSION_EXPIRED",
|
|
53
|
+
"SESSION_INVALID",
|
|
54
|
+
"UNAUTHORIZED"
|
|
55
|
+
].includes(this.code);
|
|
56
|
+
}
|
|
57
|
+
/** Check if this is a "not found" error */
|
|
58
|
+
isNotFoundError() {
|
|
59
|
+
return [
|
|
60
|
+
"NOT_FOUND",
|
|
61
|
+
"CUSTOMER_NOT_FOUND",
|
|
62
|
+
"SUBSCRIPTION_NOT_FOUND",
|
|
63
|
+
"PLAN_NOT_FOUND",
|
|
64
|
+
"APP_NOT_FOUND"
|
|
65
|
+
].includes(this.code);
|
|
66
|
+
}
|
|
42
67
|
};
|
|
43
68
|
|
|
44
69
|
// src/http.ts
|
|
70
|
+
function mapErrorCode(status, message, errorType) {
|
|
71
|
+
if (errorType && errorType !== "Error") {
|
|
72
|
+
const codeFromError = errorType.toUpperCase().replace(/\s+/g, "_");
|
|
73
|
+
if (codeFromError.includes("NOT_FOUND")) return "NOT_FOUND";
|
|
74
|
+
if (codeFromError.includes("UNAUTHORIZED")) return "UNAUTHORIZED";
|
|
75
|
+
}
|
|
76
|
+
const lowerMessage = message.toLowerCase();
|
|
77
|
+
if (lowerMessage.includes("token") && lowerMessage.includes("expired")) return "TOKEN_EXPIRED";
|
|
78
|
+
if (lowerMessage.includes("already been used")) return "TOKEN_ALREADY_USED";
|
|
79
|
+
if (lowerMessage.includes("invalid") && lowerMessage.includes("token")) return "TOKEN_INVALID";
|
|
80
|
+
if (lowerMessage.includes("session") && lowerMessage.includes("expired")) return "SESSION_EXPIRED";
|
|
81
|
+
if (lowerMessage.includes("session") && lowerMessage.includes("invalid")) return "SESSION_INVALID";
|
|
82
|
+
if (lowerMessage.includes("customer") && lowerMessage.includes("not found")) return "CUSTOMER_NOT_FOUND";
|
|
83
|
+
if (lowerMessage.includes("subscription") && lowerMessage.includes("not found")) return "SUBSCRIPTION_NOT_FOUND";
|
|
84
|
+
if (lowerMessage.includes("plan") && lowerMessage.includes("not found")) return "PLAN_NOT_FOUND";
|
|
85
|
+
if (lowerMessage.includes("app") && lowerMessage.includes("not found")) return "APP_NOT_FOUND";
|
|
86
|
+
if (lowerMessage.includes("not found")) return "NOT_FOUND";
|
|
87
|
+
if (lowerMessage.includes("limit") && lowerMessage.includes("exceeded")) return "USAGE_LIMIT_EXCEEDED";
|
|
88
|
+
if (lowerMessage.includes("metric") && lowerMessage.includes("not found")) return "METRIC_NOT_FOUND";
|
|
89
|
+
if (lowerMessage.includes("feature") && lowerMessage.includes("not available")) return "FEATURE_NOT_AVAILABLE";
|
|
90
|
+
if (lowerMessage.includes("no active subscription")) return "NO_ACTIVE_SUBSCRIPTION";
|
|
91
|
+
if (lowerMessage.includes("required")) return "MISSING_REQUIRED_FIELD";
|
|
92
|
+
if (lowerMessage.includes("invalid") && lowerMessage.includes("email")) return "INVALID_EMAIL";
|
|
93
|
+
if (status === 400) return "VALIDATION_ERROR";
|
|
94
|
+
if (status === 401) return "UNAUTHORIZED";
|
|
95
|
+
if (status === 404) return "NOT_FOUND";
|
|
96
|
+
return "UNKNOWN_ERROR";
|
|
97
|
+
}
|
|
45
98
|
var HttpClient = class {
|
|
46
99
|
constructor(config) {
|
|
47
100
|
this.config = config;
|
|
@@ -79,11 +132,9 @@ var HttpClient = class {
|
|
|
79
132
|
const data = await response.json();
|
|
80
133
|
if (!response.ok) {
|
|
81
134
|
const errorData = data;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
errorData.error || "UNKNOWN_ERROR"
|
|
86
|
-
);
|
|
135
|
+
const message = errorData.message || "Unknown error";
|
|
136
|
+
const code = errorData.code || mapErrorCode(response.status, message, errorData.error || "");
|
|
137
|
+
throw new StackBEError(message, errorData.statusCode || response.status, code);
|
|
87
138
|
}
|
|
88
139
|
return data;
|
|
89
140
|
} catch (error) {
|
|
@@ -635,14 +686,51 @@ var SubscriptionsClient = class {
|
|
|
635
686
|
};
|
|
636
687
|
|
|
637
688
|
// src/auth.ts
|
|
689
|
+
function decodeJwt(token) {
|
|
690
|
+
try {
|
|
691
|
+
const parts = token.split(".");
|
|
692
|
+
if (parts.length !== 3) return null;
|
|
693
|
+
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
|
|
694
|
+
return JSON.parse(payload);
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
638
699
|
var AuthClient = class {
|
|
639
|
-
constructor(http, appId) {
|
|
700
|
+
constructor(http, appId, options = {}) {
|
|
640
701
|
this.http = http;
|
|
641
702
|
this.appId = appId;
|
|
703
|
+
this.sessionCache = /* @__PURE__ */ new Map();
|
|
704
|
+
this.sessionCacheTTL = (options.sessionCacheTTL ?? 0) * 1e3;
|
|
705
|
+
this.devCallbackUrl = options.devCallbackUrl;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Check if we're in a development environment.
|
|
709
|
+
*/
|
|
710
|
+
isDev() {
|
|
711
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Clear the session cache (useful for testing or logout).
|
|
718
|
+
*/
|
|
719
|
+
clearCache() {
|
|
720
|
+
this.sessionCache.clear();
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Remove a specific session from the cache.
|
|
724
|
+
*/
|
|
725
|
+
invalidateSession(sessionToken) {
|
|
726
|
+
this.sessionCache.delete(sessionToken);
|
|
642
727
|
}
|
|
643
728
|
/**
|
|
644
729
|
* Send a magic link email to a customer for passwordless authentication.
|
|
645
730
|
*
|
|
731
|
+
* If `devCallbackUrl` is configured and we're in a dev environment (NODE_ENV !== 'production'),
|
|
732
|
+
* the dev callback URL will be used automatically.
|
|
733
|
+
*
|
|
646
734
|
* @example
|
|
647
735
|
* ```typescript
|
|
648
736
|
* // Send magic link
|
|
@@ -653,19 +741,26 @@ var AuthClient = class {
|
|
|
653
741
|
* redirectUrl: 'https://myapp.com/dashboard',
|
|
654
742
|
* });
|
|
655
743
|
*
|
|
656
|
-
* // For localhost development
|
|
744
|
+
* // For localhost development (auto-detected if devCallbackUrl is configured)
|
|
657
745
|
* await stackbe.auth.sendMagicLink('user@example.com', {
|
|
658
746
|
* useDev: true,
|
|
659
747
|
* });
|
|
660
748
|
* ```
|
|
661
749
|
*/
|
|
662
750
|
async sendMagicLink(email, options = {}) {
|
|
751
|
+
let useDev = options.useDev;
|
|
752
|
+
let redirectUrl = options.redirectUrl;
|
|
753
|
+
if (!redirectUrl && this.devCallbackUrl) {
|
|
754
|
+
if (useDev || this.isDev()) {
|
|
755
|
+
redirectUrl = this.devCallbackUrl;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
663
758
|
return this.http.post(
|
|
664
759
|
`/v1/apps/${this.appId}/auth/magic-link`,
|
|
665
760
|
{
|
|
666
761
|
email,
|
|
667
|
-
redirectUrl
|
|
668
|
-
useDev
|
|
762
|
+
redirectUrl,
|
|
763
|
+
useDev
|
|
669
764
|
}
|
|
670
765
|
);
|
|
671
766
|
}
|
|
@@ -673,32 +768,83 @@ var AuthClient = class {
|
|
|
673
768
|
* Verify a magic link token and get a session token.
|
|
674
769
|
* Call this when the user clicks the magic link.
|
|
675
770
|
*
|
|
771
|
+
* Returns the session token along with tenant and organization context.
|
|
772
|
+
*
|
|
676
773
|
* @example
|
|
677
774
|
* ```typescript
|
|
678
775
|
* // In your /verify route handler
|
|
679
776
|
* const { token } = req.query;
|
|
680
777
|
*
|
|
681
|
-
*
|
|
778
|
+
* try {
|
|
779
|
+
* const result = await stackbe.auth.verifyToken(token);
|
|
682
780
|
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* res.cookie('session', result.token, { httpOnly: true });
|
|
781
|
+
* // result includes: customerId, email, sessionToken, tenantId, organizationId
|
|
782
|
+
* res.cookie('session', result.sessionToken, { httpOnly: true });
|
|
686
783
|
* res.redirect('/dashboard');
|
|
687
|
-
* }
|
|
688
|
-
*
|
|
784
|
+
* } catch (error) {
|
|
785
|
+
* if (error instanceof StackBEError) {
|
|
786
|
+
* if (error.code === 'TOKEN_EXPIRED') {
|
|
787
|
+
* res.redirect('/login?error=expired');
|
|
788
|
+
* } else if (error.code === 'TOKEN_ALREADY_USED') {
|
|
789
|
+
* res.redirect('/login?error=used');
|
|
790
|
+
* }
|
|
791
|
+
* }
|
|
689
792
|
* }
|
|
690
793
|
* ```
|
|
691
794
|
*/
|
|
692
795
|
async verifyToken(token) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
{
|
|
796
|
+
const response = await fetch(
|
|
797
|
+
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/verify?token=${encodeURIComponent(token)}`,
|
|
798
|
+
{
|
|
799
|
+
headers: {
|
|
800
|
+
"Content-Type": "application/json"
|
|
801
|
+
}
|
|
802
|
+
}
|
|
696
803
|
);
|
|
804
|
+
if (!response.ok) {
|
|
805
|
+
const errorData = await response.json().catch(() => ({}));
|
|
806
|
+
const message = errorData.message || "Token verification failed";
|
|
807
|
+
let code = "TOKEN_INVALID";
|
|
808
|
+
if (message.includes("expired")) {
|
|
809
|
+
code = "TOKEN_EXPIRED";
|
|
810
|
+
} else if (message.includes("already been used")) {
|
|
811
|
+
code = "TOKEN_ALREADY_USED";
|
|
812
|
+
}
|
|
813
|
+
throw new StackBEError(message, response.status, code);
|
|
814
|
+
}
|
|
815
|
+
const data = await response.json();
|
|
816
|
+
let tenantId;
|
|
817
|
+
let organizationId;
|
|
818
|
+
let orgRole;
|
|
819
|
+
if (data.sessionToken) {
|
|
820
|
+
const payload = decodeJwt(data.sessionToken);
|
|
821
|
+
if (payload) {
|
|
822
|
+
tenantId = payload.tenantId;
|
|
823
|
+
organizationId = payload.organizationId;
|
|
824
|
+
orgRole = payload.orgRole;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
success: data.success ?? true,
|
|
829
|
+
valid: true,
|
|
830
|
+
customerId: data.customerId,
|
|
831
|
+
email: data.email,
|
|
832
|
+
sessionToken: data.sessionToken,
|
|
833
|
+
tenantId,
|
|
834
|
+
organizationId,
|
|
835
|
+
orgRole,
|
|
836
|
+
redirectUrl: data.redirectUrl
|
|
837
|
+
};
|
|
697
838
|
}
|
|
698
839
|
/**
|
|
699
840
|
* Get the current session for an authenticated customer.
|
|
700
841
|
* Use this to validate session tokens and get customer data.
|
|
701
842
|
*
|
|
843
|
+
* If `sessionCacheTTL` is configured, results are cached to reduce API calls.
|
|
844
|
+
* Use `invalidateSession()` or `clearCache()` to manually clear cached sessions.
|
|
845
|
+
*
|
|
846
|
+
* Returns session info including tenant and organization context extracted from the JWT.
|
|
847
|
+
*
|
|
702
848
|
* @example
|
|
703
849
|
* ```typescript
|
|
704
850
|
* // Validate session on each request
|
|
@@ -707,16 +853,27 @@ var AuthClient = class {
|
|
|
707
853
|
* const session = await stackbe.auth.getSession(sessionToken);
|
|
708
854
|
*
|
|
709
855
|
* if (session) {
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
856
|
+
* console.log(session.customerId);
|
|
857
|
+
* console.log(session.tenantId); // Tenant context
|
|
858
|
+
* console.log(session.organizationId); // Org context (if applicable)
|
|
859
|
+
* console.log(session.subscription);
|
|
860
|
+
* console.log(session.entitlements);
|
|
713
861
|
* }
|
|
714
862
|
* ```
|
|
715
863
|
*/
|
|
716
864
|
async getSession(sessionToken) {
|
|
865
|
+
if (this.sessionCacheTTL > 0) {
|
|
866
|
+
const cached = this.sessionCache.get(sessionToken);
|
|
867
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
868
|
+
return cached.session;
|
|
869
|
+
}
|
|
870
|
+
if (cached) {
|
|
871
|
+
this.sessionCache.delete(sessionToken);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
717
874
|
try {
|
|
718
875
|
const response = await fetch(
|
|
719
|
-
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/session
|
|
876
|
+
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/session`,
|
|
720
877
|
{
|
|
721
878
|
headers: {
|
|
722
879
|
"Authorization": `Bearer ${sessionToken}`,
|
|
@@ -726,12 +883,44 @@ var AuthClient = class {
|
|
|
726
883
|
);
|
|
727
884
|
if (!response.ok) {
|
|
728
885
|
if (response.status === 401) {
|
|
886
|
+
this.sessionCache.delete(sessionToken);
|
|
729
887
|
return null;
|
|
730
888
|
}
|
|
731
|
-
throw new
|
|
889
|
+
throw new StackBEError("Failed to get session", response.status, "SESSION_INVALID");
|
|
890
|
+
}
|
|
891
|
+
const data = await response.json();
|
|
892
|
+
let tenantId;
|
|
893
|
+
let organizationId;
|
|
894
|
+
let orgRole;
|
|
895
|
+
const payload = decodeJwt(sessionToken);
|
|
896
|
+
if (payload) {
|
|
897
|
+
tenantId = payload.tenantId;
|
|
898
|
+
organizationId = payload.organizationId;
|
|
899
|
+
orgRole = payload.orgRole;
|
|
900
|
+
}
|
|
901
|
+
const session = {
|
|
902
|
+
valid: data.valid ?? true,
|
|
903
|
+
customerId: data.customerId,
|
|
904
|
+
email: data.email,
|
|
905
|
+
expiresAt: data.expiresAt,
|
|
906
|
+
tenantId: tenantId ?? data.tenantId,
|
|
907
|
+
organizationId: data.organizationId ?? organizationId,
|
|
908
|
+
orgRole: data.orgRole ?? orgRole,
|
|
909
|
+
customer: data.customer,
|
|
910
|
+
subscription: data.subscription,
|
|
911
|
+
entitlements: data.entitlements
|
|
912
|
+
};
|
|
913
|
+
if (this.sessionCacheTTL > 0) {
|
|
914
|
+
this.sessionCache.set(sessionToken, {
|
|
915
|
+
session,
|
|
916
|
+
expiresAt: Date.now() + this.sessionCacheTTL
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return session;
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (error instanceof StackBEError) {
|
|
922
|
+
throw error;
|
|
732
923
|
}
|
|
733
|
-
return await response.json();
|
|
734
|
-
} catch {
|
|
735
924
|
return null;
|
|
736
925
|
}
|
|
737
926
|
}
|
|
@@ -843,7 +1032,10 @@ var StackBE = class {
|
|
|
843
1032
|
this.customers = new CustomersClient(this.http);
|
|
844
1033
|
this.checkout = new CheckoutClient(this.http, config.appId);
|
|
845
1034
|
this.subscriptions = new SubscriptionsClient(this.http);
|
|
846
|
-
this.auth = new AuthClient(this.http, config.appId
|
|
1035
|
+
this.auth = new AuthClient(this.http, config.appId, {
|
|
1036
|
+
sessionCacheTTL: config.sessionCacheTTL,
|
|
1037
|
+
devCallbackUrl: config.devCallbackUrl
|
|
1038
|
+
});
|
|
847
1039
|
}
|
|
848
1040
|
/**
|
|
849
1041
|
* Create a middleware for Express that tracks usage automatically.
|
package/dist/index.mjs
CHANGED
|
@@ -6,9 +6,62 @@ var StackBEError = class extends Error {
|
|
|
6
6
|
this.statusCode = statusCode;
|
|
7
7
|
this.code = code;
|
|
8
8
|
}
|
|
9
|
+
/** Check if this is a specific error type */
|
|
10
|
+
is(code) {
|
|
11
|
+
return this.code === code;
|
|
12
|
+
}
|
|
13
|
+
/** Check if this is an auth-related error */
|
|
14
|
+
isAuthError() {
|
|
15
|
+
return [
|
|
16
|
+
"TOKEN_EXPIRED",
|
|
17
|
+
"TOKEN_ALREADY_USED",
|
|
18
|
+
"TOKEN_INVALID",
|
|
19
|
+
"SESSION_EXPIRED",
|
|
20
|
+
"SESSION_INVALID",
|
|
21
|
+
"UNAUTHORIZED"
|
|
22
|
+
].includes(this.code);
|
|
23
|
+
}
|
|
24
|
+
/** Check if this is a "not found" error */
|
|
25
|
+
isNotFoundError() {
|
|
26
|
+
return [
|
|
27
|
+
"NOT_FOUND",
|
|
28
|
+
"CUSTOMER_NOT_FOUND",
|
|
29
|
+
"SUBSCRIPTION_NOT_FOUND",
|
|
30
|
+
"PLAN_NOT_FOUND",
|
|
31
|
+
"APP_NOT_FOUND"
|
|
32
|
+
].includes(this.code);
|
|
33
|
+
}
|
|
9
34
|
};
|
|
10
35
|
|
|
11
36
|
// src/http.ts
|
|
37
|
+
function mapErrorCode(status, message, errorType) {
|
|
38
|
+
if (errorType && errorType !== "Error") {
|
|
39
|
+
const codeFromError = errorType.toUpperCase().replace(/\s+/g, "_");
|
|
40
|
+
if (codeFromError.includes("NOT_FOUND")) return "NOT_FOUND";
|
|
41
|
+
if (codeFromError.includes("UNAUTHORIZED")) return "UNAUTHORIZED";
|
|
42
|
+
}
|
|
43
|
+
const lowerMessage = message.toLowerCase();
|
|
44
|
+
if (lowerMessage.includes("token") && lowerMessage.includes("expired")) return "TOKEN_EXPIRED";
|
|
45
|
+
if (lowerMessage.includes("already been used")) return "TOKEN_ALREADY_USED";
|
|
46
|
+
if (lowerMessage.includes("invalid") && lowerMessage.includes("token")) return "TOKEN_INVALID";
|
|
47
|
+
if (lowerMessage.includes("session") && lowerMessage.includes("expired")) return "SESSION_EXPIRED";
|
|
48
|
+
if (lowerMessage.includes("session") && lowerMessage.includes("invalid")) return "SESSION_INVALID";
|
|
49
|
+
if (lowerMessage.includes("customer") && lowerMessage.includes("not found")) return "CUSTOMER_NOT_FOUND";
|
|
50
|
+
if (lowerMessage.includes("subscription") && lowerMessage.includes("not found")) return "SUBSCRIPTION_NOT_FOUND";
|
|
51
|
+
if (lowerMessage.includes("plan") && lowerMessage.includes("not found")) return "PLAN_NOT_FOUND";
|
|
52
|
+
if (lowerMessage.includes("app") && lowerMessage.includes("not found")) return "APP_NOT_FOUND";
|
|
53
|
+
if (lowerMessage.includes("not found")) return "NOT_FOUND";
|
|
54
|
+
if (lowerMessage.includes("limit") && lowerMessage.includes("exceeded")) return "USAGE_LIMIT_EXCEEDED";
|
|
55
|
+
if (lowerMessage.includes("metric") && lowerMessage.includes("not found")) return "METRIC_NOT_FOUND";
|
|
56
|
+
if (lowerMessage.includes("feature") && lowerMessage.includes("not available")) return "FEATURE_NOT_AVAILABLE";
|
|
57
|
+
if (lowerMessage.includes("no active subscription")) return "NO_ACTIVE_SUBSCRIPTION";
|
|
58
|
+
if (lowerMessage.includes("required")) return "MISSING_REQUIRED_FIELD";
|
|
59
|
+
if (lowerMessage.includes("invalid") && lowerMessage.includes("email")) return "INVALID_EMAIL";
|
|
60
|
+
if (status === 400) return "VALIDATION_ERROR";
|
|
61
|
+
if (status === 401) return "UNAUTHORIZED";
|
|
62
|
+
if (status === 404) return "NOT_FOUND";
|
|
63
|
+
return "UNKNOWN_ERROR";
|
|
64
|
+
}
|
|
12
65
|
var HttpClient = class {
|
|
13
66
|
constructor(config) {
|
|
14
67
|
this.config = config;
|
|
@@ -46,11 +99,9 @@ var HttpClient = class {
|
|
|
46
99
|
const data = await response.json();
|
|
47
100
|
if (!response.ok) {
|
|
48
101
|
const errorData = data;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
errorData.error || "UNKNOWN_ERROR"
|
|
53
|
-
);
|
|
102
|
+
const message = errorData.message || "Unknown error";
|
|
103
|
+
const code = errorData.code || mapErrorCode(response.status, message, errorData.error || "");
|
|
104
|
+
throw new StackBEError(message, errorData.statusCode || response.status, code);
|
|
54
105
|
}
|
|
55
106
|
return data;
|
|
56
107
|
} catch (error) {
|
|
@@ -602,14 +653,51 @@ var SubscriptionsClient = class {
|
|
|
602
653
|
};
|
|
603
654
|
|
|
604
655
|
// src/auth.ts
|
|
656
|
+
function decodeJwt(token) {
|
|
657
|
+
try {
|
|
658
|
+
const parts = token.split(".");
|
|
659
|
+
if (parts.length !== 3) return null;
|
|
660
|
+
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
|
|
661
|
+
return JSON.parse(payload);
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
605
666
|
var AuthClient = class {
|
|
606
|
-
constructor(http, appId) {
|
|
667
|
+
constructor(http, appId, options = {}) {
|
|
607
668
|
this.http = http;
|
|
608
669
|
this.appId = appId;
|
|
670
|
+
this.sessionCache = /* @__PURE__ */ new Map();
|
|
671
|
+
this.sessionCacheTTL = (options.sessionCacheTTL ?? 0) * 1e3;
|
|
672
|
+
this.devCallbackUrl = options.devCallbackUrl;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Check if we're in a development environment.
|
|
676
|
+
*/
|
|
677
|
+
isDev() {
|
|
678
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Clear the session cache (useful for testing or logout).
|
|
685
|
+
*/
|
|
686
|
+
clearCache() {
|
|
687
|
+
this.sessionCache.clear();
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Remove a specific session from the cache.
|
|
691
|
+
*/
|
|
692
|
+
invalidateSession(sessionToken) {
|
|
693
|
+
this.sessionCache.delete(sessionToken);
|
|
609
694
|
}
|
|
610
695
|
/**
|
|
611
696
|
* Send a magic link email to a customer for passwordless authentication.
|
|
612
697
|
*
|
|
698
|
+
* If `devCallbackUrl` is configured and we're in a dev environment (NODE_ENV !== 'production'),
|
|
699
|
+
* the dev callback URL will be used automatically.
|
|
700
|
+
*
|
|
613
701
|
* @example
|
|
614
702
|
* ```typescript
|
|
615
703
|
* // Send magic link
|
|
@@ -620,19 +708,26 @@ var AuthClient = class {
|
|
|
620
708
|
* redirectUrl: 'https://myapp.com/dashboard',
|
|
621
709
|
* });
|
|
622
710
|
*
|
|
623
|
-
* // For localhost development
|
|
711
|
+
* // For localhost development (auto-detected if devCallbackUrl is configured)
|
|
624
712
|
* await stackbe.auth.sendMagicLink('user@example.com', {
|
|
625
713
|
* useDev: true,
|
|
626
714
|
* });
|
|
627
715
|
* ```
|
|
628
716
|
*/
|
|
629
717
|
async sendMagicLink(email, options = {}) {
|
|
718
|
+
let useDev = options.useDev;
|
|
719
|
+
let redirectUrl = options.redirectUrl;
|
|
720
|
+
if (!redirectUrl && this.devCallbackUrl) {
|
|
721
|
+
if (useDev || this.isDev()) {
|
|
722
|
+
redirectUrl = this.devCallbackUrl;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
630
725
|
return this.http.post(
|
|
631
726
|
`/v1/apps/${this.appId}/auth/magic-link`,
|
|
632
727
|
{
|
|
633
728
|
email,
|
|
634
|
-
redirectUrl
|
|
635
|
-
useDev
|
|
729
|
+
redirectUrl,
|
|
730
|
+
useDev
|
|
636
731
|
}
|
|
637
732
|
);
|
|
638
733
|
}
|
|
@@ -640,32 +735,83 @@ var AuthClient = class {
|
|
|
640
735
|
* Verify a magic link token and get a session token.
|
|
641
736
|
* Call this when the user clicks the magic link.
|
|
642
737
|
*
|
|
738
|
+
* Returns the session token along with tenant and organization context.
|
|
739
|
+
*
|
|
643
740
|
* @example
|
|
644
741
|
* ```typescript
|
|
645
742
|
* // In your /verify route handler
|
|
646
743
|
* const { token } = req.query;
|
|
647
744
|
*
|
|
648
|
-
*
|
|
745
|
+
* try {
|
|
746
|
+
* const result = await stackbe.auth.verifyToken(token);
|
|
649
747
|
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
* res.cookie('session', result.token, { httpOnly: true });
|
|
748
|
+
* // result includes: customerId, email, sessionToken, tenantId, organizationId
|
|
749
|
+
* res.cookie('session', result.sessionToken, { httpOnly: true });
|
|
653
750
|
* res.redirect('/dashboard');
|
|
654
|
-
* }
|
|
655
|
-
*
|
|
751
|
+
* } catch (error) {
|
|
752
|
+
* if (error instanceof StackBEError) {
|
|
753
|
+
* if (error.code === 'TOKEN_EXPIRED') {
|
|
754
|
+
* res.redirect('/login?error=expired');
|
|
755
|
+
* } else if (error.code === 'TOKEN_ALREADY_USED') {
|
|
756
|
+
* res.redirect('/login?error=used');
|
|
757
|
+
* }
|
|
758
|
+
* }
|
|
656
759
|
* }
|
|
657
760
|
* ```
|
|
658
761
|
*/
|
|
659
762
|
async verifyToken(token) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
{
|
|
763
|
+
const response = await fetch(
|
|
764
|
+
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/verify?token=${encodeURIComponent(token)}`,
|
|
765
|
+
{
|
|
766
|
+
headers: {
|
|
767
|
+
"Content-Type": "application/json"
|
|
768
|
+
}
|
|
769
|
+
}
|
|
663
770
|
);
|
|
771
|
+
if (!response.ok) {
|
|
772
|
+
const errorData = await response.json().catch(() => ({}));
|
|
773
|
+
const message = errorData.message || "Token verification failed";
|
|
774
|
+
let code = "TOKEN_INVALID";
|
|
775
|
+
if (message.includes("expired")) {
|
|
776
|
+
code = "TOKEN_EXPIRED";
|
|
777
|
+
} else if (message.includes("already been used")) {
|
|
778
|
+
code = "TOKEN_ALREADY_USED";
|
|
779
|
+
}
|
|
780
|
+
throw new StackBEError(message, response.status, code);
|
|
781
|
+
}
|
|
782
|
+
const data = await response.json();
|
|
783
|
+
let tenantId;
|
|
784
|
+
let organizationId;
|
|
785
|
+
let orgRole;
|
|
786
|
+
if (data.sessionToken) {
|
|
787
|
+
const payload = decodeJwt(data.sessionToken);
|
|
788
|
+
if (payload) {
|
|
789
|
+
tenantId = payload.tenantId;
|
|
790
|
+
organizationId = payload.organizationId;
|
|
791
|
+
orgRole = payload.orgRole;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
success: data.success ?? true,
|
|
796
|
+
valid: true,
|
|
797
|
+
customerId: data.customerId,
|
|
798
|
+
email: data.email,
|
|
799
|
+
sessionToken: data.sessionToken,
|
|
800
|
+
tenantId,
|
|
801
|
+
organizationId,
|
|
802
|
+
orgRole,
|
|
803
|
+
redirectUrl: data.redirectUrl
|
|
804
|
+
};
|
|
664
805
|
}
|
|
665
806
|
/**
|
|
666
807
|
* Get the current session for an authenticated customer.
|
|
667
808
|
* Use this to validate session tokens and get customer data.
|
|
668
809
|
*
|
|
810
|
+
* If `sessionCacheTTL` is configured, results are cached to reduce API calls.
|
|
811
|
+
* Use `invalidateSession()` or `clearCache()` to manually clear cached sessions.
|
|
812
|
+
*
|
|
813
|
+
* Returns session info including tenant and organization context extracted from the JWT.
|
|
814
|
+
*
|
|
669
815
|
* @example
|
|
670
816
|
* ```typescript
|
|
671
817
|
* // Validate session on each request
|
|
@@ -674,16 +820,27 @@ var AuthClient = class {
|
|
|
674
820
|
* const session = await stackbe.auth.getSession(sessionToken);
|
|
675
821
|
*
|
|
676
822
|
* if (session) {
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
823
|
+
* console.log(session.customerId);
|
|
824
|
+
* console.log(session.tenantId); // Tenant context
|
|
825
|
+
* console.log(session.organizationId); // Org context (if applicable)
|
|
826
|
+
* console.log(session.subscription);
|
|
827
|
+
* console.log(session.entitlements);
|
|
680
828
|
* }
|
|
681
829
|
* ```
|
|
682
830
|
*/
|
|
683
831
|
async getSession(sessionToken) {
|
|
832
|
+
if (this.sessionCacheTTL > 0) {
|
|
833
|
+
const cached = this.sessionCache.get(sessionToken);
|
|
834
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
835
|
+
return cached.session;
|
|
836
|
+
}
|
|
837
|
+
if (cached) {
|
|
838
|
+
this.sessionCache.delete(sessionToken);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
684
841
|
try {
|
|
685
842
|
const response = await fetch(
|
|
686
|
-
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/session
|
|
843
|
+
`${this.http.baseUrl}/v1/apps/${this.appId}/auth/session`,
|
|
687
844
|
{
|
|
688
845
|
headers: {
|
|
689
846
|
"Authorization": `Bearer ${sessionToken}`,
|
|
@@ -693,12 +850,44 @@ var AuthClient = class {
|
|
|
693
850
|
);
|
|
694
851
|
if (!response.ok) {
|
|
695
852
|
if (response.status === 401) {
|
|
853
|
+
this.sessionCache.delete(sessionToken);
|
|
696
854
|
return null;
|
|
697
855
|
}
|
|
698
|
-
throw new
|
|
856
|
+
throw new StackBEError("Failed to get session", response.status, "SESSION_INVALID");
|
|
857
|
+
}
|
|
858
|
+
const data = await response.json();
|
|
859
|
+
let tenantId;
|
|
860
|
+
let organizationId;
|
|
861
|
+
let orgRole;
|
|
862
|
+
const payload = decodeJwt(sessionToken);
|
|
863
|
+
if (payload) {
|
|
864
|
+
tenantId = payload.tenantId;
|
|
865
|
+
organizationId = payload.organizationId;
|
|
866
|
+
orgRole = payload.orgRole;
|
|
867
|
+
}
|
|
868
|
+
const session = {
|
|
869
|
+
valid: data.valid ?? true,
|
|
870
|
+
customerId: data.customerId,
|
|
871
|
+
email: data.email,
|
|
872
|
+
expiresAt: data.expiresAt,
|
|
873
|
+
tenantId: tenantId ?? data.tenantId,
|
|
874
|
+
organizationId: data.organizationId ?? organizationId,
|
|
875
|
+
orgRole: data.orgRole ?? orgRole,
|
|
876
|
+
customer: data.customer,
|
|
877
|
+
subscription: data.subscription,
|
|
878
|
+
entitlements: data.entitlements
|
|
879
|
+
};
|
|
880
|
+
if (this.sessionCacheTTL > 0) {
|
|
881
|
+
this.sessionCache.set(sessionToken, {
|
|
882
|
+
session,
|
|
883
|
+
expiresAt: Date.now() + this.sessionCacheTTL
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return session;
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (error instanceof StackBEError) {
|
|
889
|
+
throw error;
|
|
699
890
|
}
|
|
700
|
-
return await response.json();
|
|
701
|
-
} catch {
|
|
702
891
|
return null;
|
|
703
892
|
}
|
|
704
893
|
}
|
|
@@ -810,7 +999,10 @@ var StackBE = class {
|
|
|
810
999
|
this.customers = new CustomersClient(this.http);
|
|
811
1000
|
this.checkout = new CheckoutClient(this.http, config.appId);
|
|
812
1001
|
this.subscriptions = new SubscriptionsClient(this.http);
|
|
813
|
-
this.auth = new AuthClient(this.http, config.appId
|
|
1002
|
+
this.auth = new AuthClient(this.http, config.appId, {
|
|
1003
|
+
sessionCacheTTL: config.sessionCacheTTL,
|
|
1004
|
+
devCallbackUrl: config.devCallbackUrl
|
|
1005
|
+
});
|
|
814
1006
|
}
|
|
815
1007
|
/**
|
|
816
1008
|
* Create a middleware for Express that tracks usage automatically.
|
package/package.json
CHANGED