@stackbe/sdk 0.1.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.js CHANGED
@@ -20,10 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AuthClient: () => AuthClient,
24
+ CheckoutClient: () => CheckoutClient,
23
25
  CustomersClient: () => CustomersClient,
24
26
  EntitlementsClient: () => EntitlementsClient,
25
27
  StackBE: () => StackBE,
26
28
  StackBEError: () => StackBEError,
29
+ SubscriptionsClient: () => SubscriptionsClient,
27
30
  UsageClient: () => UsageClient
28
31
  });
29
32
  module.exports = __toCommonJS(index_exports);
@@ -36,13 +39,69 @@ var StackBEError = class extends Error {
36
39
  this.statusCode = statusCode;
37
40
  this.code = code;
38
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
+ }
39
67
  };
40
68
 
41
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
+ }
42
98
  var HttpClient = class {
43
99
  constructor(config) {
44
100
  this.config = config;
45
101
  }
102
+ get baseUrl() {
103
+ return this.config.baseUrl;
104
+ }
46
105
  async request(method, path, options = {}) {
47
106
  const url = new URL(path, this.config.baseUrl);
48
107
  if (options.params) {
@@ -73,11 +132,9 @@ var HttpClient = class {
73
132
  const data = await response.json();
74
133
  if (!response.ok) {
75
134
  const errorData = data;
76
- throw new StackBEError(
77
- errorData.message || "Unknown error",
78
- errorData.statusCode || response.status,
79
- errorData.error || "UNKNOWN_ERROR"
80
- );
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);
81
138
  }
82
139
  return data;
83
140
  } catch (error) {
@@ -409,6 +466,463 @@ var CustomersClient = class {
409
466
  }
410
467
  };
411
468
 
469
+ // src/checkout.ts
470
+ var CheckoutClient = class {
471
+ constructor(http, appId) {
472
+ this.http = http;
473
+ this.appId = appId;
474
+ }
475
+ /**
476
+ * Create a checkout session for a customer to subscribe to a plan.
477
+ * Returns a URL to redirect the customer to Stripe checkout.
478
+ *
479
+ * @example
480
+ * ```typescript
481
+ * // With existing customer ID
482
+ * const { url } = await stackbe.checkout.createSession({
483
+ * customer: 'cust_123',
484
+ * planId: 'plan_pro_monthly',
485
+ * successUrl: 'https://myapp.com/success',
486
+ * cancelUrl: 'https://myapp.com/pricing',
487
+ * });
488
+ *
489
+ * // Redirect to checkout
490
+ * res.redirect(url);
491
+ * ```
492
+ *
493
+ * @example
494
+ * ```typescript
495
+ * // With new customer (will be created)
496
+ * const { url } = await stackbe.checkout.createSession({
497
+ * customer: { email: 'user@example.com', name: 'John' },
498
+ * planId: 'plan_pro_monthly',
499
+ * successUrl: 'https://myapp.com/success',
500
+ * trialDays: 14,
501
+ * });
502
+ * ```
503
+ */
504
+ async createSession(options) {
505
+ const body = {
506
+ planId: options.planId,
507
+ successUrl: options.successUrl,
508
+ cancelUrl: options.cancelUrl,
509
+ allowPromotionCodes: options.allowPromotionCodes,
510
+ trialDays: options.trialDays,
511
+ metadata: options.metadata
512
+ };
513
+ if (typeof options.customer === "string") {
514
+ body.customerId = options.customer;
515
+ } else {
516
+ body.customerEmail = options.customer.email;
517
+ body.customerName = options.customer.name;
518
+ }
519
+ return this.http.post("/v1/checkout/session", body);
520
+ }
521
+ /**
522
+ * Get an existing checkout session by ID.
523
+ *
524
+ * @example
525
+ * ```typescript
526
+ * const session = await stackbe.checkout.getSession('cs_123');
527
+ * if (session.status === 'complete') {
528
+ * // Payment successful
529
+ * }
530
+ * ```
531
+ */
532
+ async getSession(sessionId) {
533
+ return this.http.get(`/v1/checkout/session/${sessionId}`);
534
+ }
535
+ /**
536
+ * Generate a checkout URL for a plan.
537
+ * Convenience method that creates a session and returns just the URL.
538
+ *
539
+ * @example
540
+ * ```typescript
541
+ * const checkoutUrl = await stackbe.checkout.getCheckoutUrl({
542
+ * customer: 'cust_123',
543
+ * planId: 'plan_pro_monthly',
544
+ * successUrl: 'https://myapp.com/success',
545
+ * });
546
+ *
547
+ * // Send to frontend
548
+ * res.json({ checkoutUrl });
549
+ * ```
550
+ */
551
+ async getCheckoutUrl(options) {
552
+ const session = await this.createSession(options);
553
+ return session.url;
554
+ }
555
+ };
556
+
557
+ // src/subscriptions.ts
558
+ var SubscriptionsClient = class {
559
+ constructor(http) {
560
+ this.http = http;
561
+ }
562
+ /**
563
+ * Get a customer's current active subscription.
564
+ *
565
+ * @example
566
+ * ```typescript
567
+ * const subscription = await stackbe.subscriptions.get('cust_123');
568
+ *
569
+ * if (subscription) {
570
+ * console.log(`Plan: ${subscription.plan.name}`);
571
+ * console.log(`Status: ${subscription.status}`);
572
+ * console.log(`Renews: ${subscription.currentPeriodEnd}`);
573
+ * } else {
574
+ * console.log('No active subscription');
575
+ * }
576
+ * ```
577
+ */
578
+ async get(customerId) {
579
+ try {
580
+ return await this.http.get(
581
+ `/v1/subscriptions/current`,
582
+ { customerId }
583
+ );
584
+ } catch (error) {
585
+ if (error instanceof Error && "statusCode" in error && error.statusCode === 404) {
586
+ return null;
587
+ }
588
+ throw error;
589
+ }
590
+ }
591
+ /**
592
+ * Get a subscription by ID.
593
+ *
594
+ * @example
595
+ * ```typescript
596
+ * const subscription = await stackbe.subscriptions.getById('sub_123');
597
+ * ```
598
+ */
599
+ async getById(subscriptionId) {
600
+ return this.http.get(`/v1/subscriptions/${subscriptionId}`);
601
+ }
602
+ /**
603
+ * List all subscriptions for a customer.
604
+ *
605
+ * @example
606
+ * ```typescript
607
+ * const subscriptions = await stackbe.subscriptions.list('cust_123');
608
+ * ```
609
+ */
610
+ async list(customerId) {
611
+ return this.http.get("/v1/subscriptions", { customerId });
612
+ }
613
+ /**
614
+ * Cancel a subscription.
615
+ *
616
+ * @example
617
+ * ```typescript
618
+ * // Cancel at end of billing period (default)
619
+ * await stackbe.subscriptions.cancel('sub_123');
620
+ *
621
+ * // Cancel immediately
622
+ * await stackbe.subscriptions.cancel('sub_123', { immediate: true });
623
+ *
624
+ * // Cancel with reason
625
+ * await stackbe.subscriptions.cancel('sub_123', {
626
+ * reason: 'Too expensive'
627
+ * });
628
+ * ```
629
+ */
630
+ async cancel(subscriptionId, options = {}) {
631
+ return this.http.post(
632
+ `/v1/subscriptions/${subscriptionId}/cancel`,
633
+ {
634
+ immediate: options.immediate ?? false,
635
+ reason: options.reason
636
+ }
637
+ );
638
+ }
639
+ /**
640
+ * Update a subscription (change plan).
641
+ *
642
+ * @example
643
+ * ```typescript
644
+ * // Upgrade/downgrade to a different plan
645
+ * await stackbe.subscriptions.update('sub_123', {
646
+ * planId: 'plan_enterprise_monthly',
647
+ * prorate: true,
648
+ * });
649
+ * ```
650
+ */
651
+ async update(subscriptionId, options) {
652
+ return this.http.patch(
653
+ `/v1/subscriptions/${subscriptionId}`,
654
+ options
655
+ );
656
+ }
657
+ /**
658
+ * Reactivate a canceled subscription (before it ends).
659
+ *
660
+ * @example
661
+ * ```typescript
662
+ * // Customer changed their mind
663
+ * await stackbe.subscriptions.reactivate('sub_123');
664
+ * ```
665
+ */
666
+ async reactivate(subscriptionId) {
667
+ return this.http.post(
668
+ `/v1/subscriptions/${subscriptionId}/reactivate`
669
+ );
670
+ }
671
+ /**
672
+ * Check if a customer has an active subscription.
673
+ *
674
+ * @example
675
+ * ```typescript
676
+ * const isActive = await stackbe.subscriptions.isActive('cust_123');
677
+ * if (!isActive) {
678
+ * return res.redirect('/pricing');
679
+ * }
680
+ * ```
681
+ */
682
+ async isActive(customerId) {
683
+ const subscription = await this.get(customerId);
684
+ return subscription !== null && (subscription.status === "active" || subscription.status === "trialing");
685
+ }
686
+ };
687
+
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
+ }
699
+ var AuthClient = class {
700
+ constructor(http, appId) {
701
+ this.http = http;
702
+ this.appId = appId;
703
+ }
704
+ /**
705
+ * Send a magic link email to a customer for passwordless authentication.
706
+ *
707
+ * @example
708
+ * ```typescript
709
+ * // Send magic link
710
+ * await stackbe.auth.sendMagicLink('user@example.com');
711
+ *
712
+ * // With redirect URL
713
+ * await stackbe.auth.sendMagicLink('user@example.com', {
714
+ * redirectUrl: 'https://myapp.com/dashboard',
715
+ * });
716
+ *
717
+ * // For localhost development
718
+ * await stackbe.auth.sendMagicLink('user@example.com', {
719
+ * useDev: true,
720
+ * });
721
+ * ```
722
+ */
723
+ async sendMagicLink(email, options = {}) {
724
+ return this.http.post(
725
+ `/v1/apps/${this.appId}/auth/magic-link`,
726
+ {
727
+ email,
728
+ redirectUrl: options.redirectUrl,
729
+ useDev: options.useDev
730
+ }
731
+ );
732
+ }
733
+ /**
734
+ * Verify a magic link token and get a session token.
735
+ * Call this when the user clicks the magic link.
736
+ *
737
+ * Returns the session token along with tenant and organization context.
738
+ *
739
+ * @example
740
+ * ```typescript
741
+ * // In your /verify route handler
742
+ * const { token } = req.query;
743
+ *
744
+ * try {
745
+ * const result = await stackbe.auth.verifyToken(token);
746
+ *
747
+ * // result includes: customerId, email, sessionToken, tenantId, organizationId
748
+ * res.cookie('session', result.sessionToken, { httpOnly: true });
749
+ * res.redirect('/dashboard');
750
+ * } catch (error) {
751
+ * if (error instanceof StackBEError) {
752
+ * if (error.code === 'TOKEN_EXPIRED') {
753
+ * res.redirect('/login?error=expired');
754
+ * } else if (error.code === 'TOKEN_ALREADY_USED') {
755
+ * res.redirect('/login?error=used');
756
+ * }
757
+ * }
758
+ * }
759
+ * ```
760
+ */
761
+ async verifyToken(token) {
762
+ const response = await fetch(
763
+ `${this.http.baseUrl}/v1/apps/${this.appId}/auth/verify?token=${encodeURIComponent(token)}`,
764
+ {
765
+ headers: {
766
+ "Content-Type": "application/json"
767
+ }
768
+ }
769
+ );
770
+ if (!response.ok) {
771
+ const errorData = await response.json().catch(() => ({}));
772
+ const message = errorData.message || "Token verification failed";
773
+ let code = "TOKEN_INVALID";
774
+ if (message.includes("expired")) {
775
+ code = "TOKEN_EXPIRED";
776
+ } else if (message.includes("already been used")) {
777
+ code = "TOKEN_ALREADY_USED";
778
+ }
779
+ throw new StackBEError(message, response.status, code);
780
+ }
781
+ const data = await response.json();
782
+ let tenantId;
783
+ let organizationId;
784
+ let orgRole;
785
+ if (data.sessionToken) {
786
+ const payload = decodeJwt(data.sessionToken);
787
+ if (payload) {
788
+ tenantId = payload.tenantId;
789
+ organizationId = payload.organizationId;
790
+ orgRole = payload.orgRole;
791
+ }
792
+ }
793
+ return {
794
+ success: data.success ?? true,
795
+ valid: true,
796
+ customerId: data.customerId,
797
+ email: data.email,
798
+ sessionToken: data.sessionToken,
799
+ tenantId,
800
+ organizationId,
801
+ orgRole,
802
+ redirectUrl: data.redirectUrl
803
+ };
804
+ }
805
+ /**
806
+ * Get the current session for an authenticated customer.
807
+ * Use this to validate session tokens and get customer data.
808
+ *
809
+ * Returns session info including tenant and organization context extracted from the JWT.
810
+ *
811
+ * @example
812
+ * ```typescript
813
+ * // Validate session on each request
814
+ * const sessionToken = req.cookies.session;
815
+ *
816
+ * const session = await stackbe.auth.getSession(sessionToken);
817
+ *
818
+ * if (session) {
819
+ * console.log(session.customerId);
820
+ * console.log(session.tenantId); // Tenant context
821
+ * console.log(session.organizationId); // Org context (if applicable)
822
+ * console.log(session.subscription);
823
+ * console.log(session.entitlements);
824
+ * }
825
+ * ```
826
+ */
827
+ async getSession(sessionToken) {
828
+ try {
829
+ const response = await fetch(
830
+ `${this.http.baseUrl}/v1/apps/${this.appId}/auth/session`,
831
+ {
832
+ headers: {
833
+ "Authorization": `Bearer ${sessionToken}`,
834
+ "Content-Type": "application/json"
835
+ }
836
+ }
837
+ );
838
+ if (!response.ok) {
839
+ if (response.status === 401) {
840
+ return null;
841
+ }
842
+ throw new StackBEError("Failed to get session", response.status, "SESSION_INVALID");
843
+ }
844
+ const data = await response.json();
845
+ let tenantId;
846
+ let organizationId;
847
+ let orgRole;
848
+ const payload = decodeJwt(sessionToken);
849
+ if (payload) {
850
+ tenantId = payload.tenantId;
851
+ organizationId = payload.organizationId;
852
+ orgRole = payload.orgRole;
853
+ }
854
+ return {
855
+ valid: data.valid ?? true,
856
+ customerId: data.customerId,
857
+ email: data.email,
858
+ expiresAt: data.expiresAt,
859
+ tenantId: tenantId ?? data.tenantId,
860
+ organizationId: data.organizationId ?? organizationId,
861
+ orgRole: data.orgRole ?? orgRole,
862
+ customer: data.customer,
863
+ subscription: data.subscription,
864
+ entitlements: data.entitlements
865
+ };
866
+ } catch (error) {
867
+ if (error instanceof StackBEError) {
868
+ throw error;
869
+ }
870
+ return null;
871
+ }
872
+ }
873
+ /**
874
+ * Create Express middleware that validates session tokens.
875
+ *
876
+ * @example
877
+ * ```typescript
878
+ * // Protect routes with authentication
879
+ * app.use('/dashboard', stackbe.auth.middleware({
880
+ * getToken: (req) => req.cookies.session,
881
+ * onUnauthenticated: (req, res) => res.redirect('/login'),
882
+ * }));
883
+ *
884
+ * app.get('/dashboard', (req, res) => {
885
+ * // req.customer is available
886
+ * res.json({ email: req.customer.email });
887
+ * });
888
+ * ```
889
+ */
890
+ middleware(options) {
891
+ return async (req, res, next) => {
892
+ const token = options.getToken(req);
893
+ if (!token) {
894
+ if (options.onUnauthenticated) {
895
+ return options.onUnauthenticated(req, res);
896
+ }
897
+ return res.status(401).json({ error: "Not authenticated" });
898
+ }
899
+ const session = await this.getSession(token);
900
+ if (!session) {
901
+ if (options.onUnauthenticated) {
902
+ return options.onUnauthenticated(req, res);
903
+ }
904
+ return res.status(401).json({ error: "Invalid session" });
905
+ }
906
+ req.customer = session.customer;
907
+ req.subscription = session.subscription;
908
+ req.entitlements = session.entitlements;
909
+ next();
910
+ };
911
+ }
912
+ /**
913
+ * Check if a session token is valid.
914
+ *
915
+ * @example
916
+ * ```typescript
917
+ * const isValid = await stackbe.auth.isAuthenticated(sessionToken);
918
+ * ```
919
+ */
920
+ async isAuthenticated(sessionToken) {
921
+ const session = await this.getSession(sessionToken);
922
+ return session !== null;
923
+ }
924
+ };
925
+
412
926
  // src/client.ts
413
927
  var DEFAULT_BASE_URL = "https://api.stackbe.io";
414
928
  var DEFAULT_TIMEOUT = 3e4;
@@ -431,8 +945,18 @@ var StackBE = class {
431
945
  * // Check entitlements
432
946
  * const { hasAccess } = await stackbe.entitlements.check('customer_123', 'premium');
433
947
  *
434
- * // Get customer
435
- * const customer = await stackbe.customers.get('customer_123');
948
+ * // Create checkout session
949
+ * const { url } = await stackbe.checkout.createSession({
950
+ * customer: 'cust_123',
951
+ * planId: 'plan_pro',
952
+ * successUrl: 'https://myapp.com/success',
953
+ * });
954
+ *
955
+ * // Get subscription
956
+ * const subscription = await stackbe.subscriptions.get('cust_123');
957
+ *
958
+ * // Send magic link
959
+ * await stackbe.auth.sendMagicLink('user@example.com');
436
960
  * ```
437
961
  */
438
962
  constructor(config) {
@@ -442,6 +966,7 @@ var StackBE = class {
442
966
  if (!config.appId) {
443
967
  throw new StackBEError("appId is required", 400, "INVALID_CONFIG");
444
968
  }
969
+ this.appId = config.appId;
445
970
  this.http = new HttpClient({
446
971
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
447
972
  apiKey: config.apiKey,
@@ -451,6 +976,9 @@ var StackBE = class {
451
976
  this.usage = new UsageClient(this.http);
452
977
  this.entitlements = new EntitlementsClient(this.http);
453
978
  this.customers = new CustomersClient(this.http);
979
+ this.checkout = new CheckoutClient(this.http, config.appId);
980
+ this.subscriptions = new SubscriptionsClient(this.http);
981
+ this.auth = new AuthClient(this.http, config.appId);
454
982
  }
455
983
  /**
456
984
  * Create a middleware for Express that tracks usage automatically.
@@ -584,9 +1112,12 @@ var StackBE = class {
584
1112
  };
585
1113
  // Annotate the CommonJS export names for ESM import in node:
586
1114
  0 && (module.exports = {
1115
+ AuthClient,
1116
+ CheckoutClient,
587
1117
  CustomersClient,
588
1118
  EntitlementsClient,
589
1119
  StackBE,
590
1120
  StackBEError,
1121
+ SubscriptionsClient,
591
1122
  UsageClient
592
1123
  });