@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.mjs CHANGED
@@ -6,13 +6,69 @@ 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;
15
68
  }
69
+ get baseUrl() {
70
+ return this.config.baseUrl;
71
+ }
16
72
  async request(method, path, options = {}) {
17
73
  const url = new URL(path, this.config.baseUrl);
18
74
  if (options.params) {
@@ -43,11 +99,9 @@ var HttpClient = class {
43
99
  const data = await response.json();
44
100
  if (!response.ok) {
45
101
  const errorData = data;
46
- throw new StackBEError(
47
- errorData.message || "Unknown error",
48
- errorData.statusCode || response.status,
49
- errorData.error || "UNKNOWN_ERROR"
50
- );
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);
51
105
  }
52
106
  return data;
53
107
  } catch (error) {
@@ -379,6 +433,463 @@ var CustomersClient = class {
379
433
  }
380
434
  };
381
435
 
436
+ // src/checkout.ts
437
+ var CheckoutClient = class {
438
+ constructor(http, appId) {
439
+ this.http = http;
440
+ this.appId = appId;
441
+ }
442
+ /**
443
+ * Create a checkout session for a customer to subscribe to a plan.
444
+ * Returns a URL to redirect the customer to Stripe checkout.
445
+ *
446
+ * @example
447
+ * ```typescript
448
+ * // With existing customer ID
449
+ * const { url } = await stackbe.checkout.createSession({
450
+ * customer: 'cust_123',
451
+ * planId: 'plan_pro_monthly',
452
+ * successUrl: 'https://myapp.com/success',
453
+ * cancelUrl: 'https://myapp.com/pricing',
454
+ * });
455
+ *
456
+ * // Redirect to checkout
457
+ * res.redirect(url);
458
+ * ```
459
+ *
460
+ * @example
461
+ * ```typescript
462
+ * // With new customer (will be created)
463
+ * const { url } = await stackbe.checkout.createSession({
464
+ * customer: { email: 'user@example.com', name: 'John' },
465
+ * planId: 'plan_pro_monthly',
466
+ * successUrl: 'https://myapp.com/success',
467
+ * trialDays: 14,
468
+ * });
469
+ * ```
470
+ */
471
+ async createSession(options) {
472
+ const body = {
473
+ planId: options.planId,
474
+ successUrl: options.successUrl,
475
+ cancelUrl: options.cancelUrl,
476
+ allowPromotionCodes: options.allowPromotionCodes,
477
+ trialDays: options.trialDays,
478
+ metadata: options.metadata
479
+ };
480
+ if (typeof options.customer === "string") {
481
+ body.customerId = options.customer;
482
+ } else {
483
+ body.customerEmail = options.customer.email;
484
+ body.customerName = options.customer.name;
485
+ }
486
+ return this.http.post("/v1/checkout/session", body);
487
+ }
488
+ /**
489
+ * Get an existing checkout session by ID.
490
+ *
491
+ * @example
492
+ * ```typescript
493
+ * const session = await stackbe.checkout.getSession('cs_123');
494
+ * if (session.status === 'complete') {
495
+ * // Payment successful
496
+ * }
497
+ * ```
498
+ */
499
+ async getSession(sessionId) {
500
+ return this.http.get(`/v1/checkout/session/${sessionId}`);
501
+ }
502
+ /**
503
+ * Generate a checkout URL for a plan.
504
+ * Convenience method that creates a session and returns just the URL.
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const checkoutUrl = await stackbe.checkout.getCheckoutUrl({
509
+ * customer: 'cust_123',
510
+ * planId: 'plan_pro_monthly',
511
+ * successUrl: 'https://myapp.com/success',
512
+ * });
513
+ *
514
+ * // Send to frontend
515
+ * res.json({ checkoutUrl });
516
+ * ```
517
+ */
518
+ async getCheckoutUrl(options) {
519
+ const session = await this.createSession(options);
520
+ return session.url;
521
+ }
522
+ };
523
+
524
+ // src/subscriptions.ts
525
+ var SubscriptionsClient = class {
526
+ constructor(http) {
527
+ this.http = http;
528
+ }
529
+ /**
530
+ * Get a customer's current active subscription.
531
+ *
532
+ * @example
533
+ * ```typescript
534
+ * const subscription = await stackbe.subscriptions.get('cust_123');
535
+ *
536
+ * if (subscription) {
537
+ * console.log(`Plan: ${subscription.plan.name}`);
538
+ * console.log(`Status: ${subscription.status}`);
539
+ * console.log(`Renews: ${subscription.currentPeriodEnd}`);
540
+ * } else {
541
+ * console.log('No active subscription');
542
+ * }
543
+ * ```
544
+ */
545
+ async get(customerId) {
546
+ try {
547
+ return await this.http.get(
548
+ `/v1/subscriptions/current`,
549
+ { customerId }
550
+ );
551
+ } catch (error) {
552
+ if (error instanceof Error && "statusCode" in error && error.statusCode === 404) {
553
+ return null;
554
+ }
555
+ throw error;
556
+ }
557
+ }
558
+ /**
559
+ * Get a subscription by ID.
560
+ *
561
+ * @example
562
+ * ```typescript
563
+ * const subscription = await stackbe.subscriptions.getById('sub_123');
564
+ * ```
565
+ */
566
+ async getById(subscriptionId) {
567
+ return this.http.get(`/v1/subscriptions/${subscriptionId}`);
568
+ }
569
+ /**
570
+ * List all subscriptions for a customer.
571
+ *
572
+ * @example
573
+ * ```typescript
574
+ * const subscriptions = await stackbe.subscriptions.list('cust_123');
575
+ * ```
576
+ */
577
+ async list(customerId) {
578
+ return this.http.get("/v1/subscriptions", { customerId });
579
+ }
580
+ /**
581
+ * Cancel a subscription.
582
+ *
583
+ * @example
584
+ * ```typescript
585
+ * // Cancel at end of billing period (default)
586
+ * await stackbe.subscriptions.cancel('sub_123');
587
+ *
588
+ * // Cancel immediately
589
+ * await stackbe.subscriptions.cancel('sub_123', { immediate: true });
590
+ *
591
+ * // Cancel with reason
592
+ * await stackbe.subscriptions.cancel('sub_123', {
593
+ * reason: 'Too expensive'
594
+ * });
595
+ * ```
596
+ */
597
+ async cancel(subscriptionId, options = {}) {
598
+ return this.http.post(
599
+ `/v1/subscriptions/${subscriptionId}/cancel`,
600
+ {
601
+ immediate: options.immediate ?? false,
602
+ reason: options.reason
603
+ }
604
+ );
605
+ }
606
+ /**
607
+ * Update a subscription (change plan).
608
+ *
609
+ * @example
610
+ * ```typescript
611
+ * // Upgrade/downgrade to a different plan
612
+ * await stackbe.subscriptions.update('sub_123', {
613
+ * planId: 'plan_enterprise_monthly',
614
+ * prorate: true,
615
+ * });
616
+ * ```
617
+ */
618
+ async update(subscriptionId, options) {
619
+ return this.http.patch(
620
+ `/v1/subscriptions/${subscriptionId}`,
621
+ options
622
+ );
623
+ }
624
+ /**
625
+ * Reactivate a canceled subscription (before it ends).
626
+ *
627
+ * @example
628
+ * ```typescript
629
+ * // Customer changed their mind
630
+ * await stackbe.subscriptions.reactivate('sub_123');
631
+ * ```
632
+ */
633
+ async reactivate(subscriptionId) {
634
+ return this.http.post(
635
+ `/v1/subscriptions/${subscriptionId}/reactivate`
636
+ );
637
+ }
638
+ /**
639
+ * Check if a customer has an active subscription.
640
+ *
641
+ * @example
642
+ * ```typescript
643
+ * const isActive = await stackbe.subscriptions.isActive('cust_123');
644
+ * if (!isActive) {
645
+ * return res.redirect('/pricing');
646
+ * }
647
+ * ```
648
+ */
649
+ async isActive(customerId) {
650
+ const subscription = await this.get(customerId);
651
+ return subscription !== null && (subscription.status === "active" || subscription.status === "trialing");
652
+ }
653
+ };
654
+
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
+ }
666
+ var AuthClient = class {
667
+ constructor(http, appId) {
668
+ this.http = http;
669
+ this.appId = appId;
670
+ }
671
+ /**
672
+ * Send a magic link email to a customer for passwordless authentication.
673
+ *
674
+ * @example
675
+ * ```typescript
676
+ * // Send magic link
677
+ * await stackbe.auth.sendMagicLink('user@example.com');
678
+ *
679
+ * // With redirect URL
680
+ * await stackbe.auth.sendMagicLink('user@example.com', {
681
+ * redirectUrl: 'https://myapp.com/dashboard',
682
+ * });
683
+ *
684
+ * // For localhost development
685
+ * await stackbe.auth.sendMagicLink('user@example.com', {
686
+ * useDev: true,
687
+ * });
688
+ * ```
689
+ */
690
+ async sendMagicLink(email, options = {}) {
691
+ return this.http.post(
692
+ `/v1/apps/${this.appId}/auth/magic-link`,
693
+ {
694
+ email,
695
+ redirectUrl: options.redirectUrl,
696
+ useDev: options.useDev
697
+ }
698
+ );
699
+ }
700
+ /**
701
+ * Verify a magic link token and get a session token.
702
+ * Call this when the user clicks the magic link.
703
+ *
704
+ * Returns the session token along with tenant and organization context.
705
+ *
706
+ * @example
707
+ * ```typescript
708
+ * // In your /verify route handler
709
+ * const { token } = req.query;
710
+ *
711
+ * try {
712
+ * const result = await stackbe.auth.verifyToken(token);
713
+ *
714
+ * // result includes: customerId, email, sessionToken, tenantId, organizationId
715
+ * res.cookie('session', result.sessionToken, { httpOnly: true });
716
+ * res.redirect('/dashboard');
717
+ * } catch (error) {
718
+ * if (error instanceof StackBEError) {
719
+ * if (error.code === 'TOKEN_EXPIRED') {
720
+ * res.redirect('/login?error=expired');
721
+ * } else if (error.code === 'TOKEN_ALREADY_USED') {
722
+ * res.redirect('/login?error=used');
723
+ * }
724
+ * }
725
+ * }
726
+ * ```
727
+ */
728
+ async verifyToken(token) {
729
+ const response = await fetch(
730
+ `${this.http.baseUrl}/v1/apps/${this.appId}/auth/verify?token=${encodeURIComponent(token)}`,
731
+ {
732
+ headers: {
733
+ "Content-Type": "application/json"
734
+ }
735
+ }
736
+ );
737
+ if (!response.ok) {
738
+ const errorData = await response.json().catch(() => ({}));
739
+ const message = errorData.message || "Token verification failed";
740
+ let code = "TOKEN_INVALID";
741
+ if (message.includes("expired")) {
742
+ code = "TOKEN_EXPIRED";
743
+ } else if (message.includes("already been used")) {
744
+ code = "TOKEN_ALREADY_USED";
745
+ }
746
+ throw new StackBEError(message, response.status, code);
747
+ }
748
+ const data = await response.json();
749
+ let tenantId;
750
+ let organizationId;
751
+ let orgRole;
752
+ if (data.sessionToken) {
753
+ const payload = decodeJwt(data.sessionToken);
754
+ if (payload) {
755
+ tenantId = payload.tenantId;
756
+ organizationId = payload.organizationId;
757
+ orgRole = payload.orgRole;
758
+ }
759
+ }
760
+ return {
761
+ success: data.success ?? true,
762
+ valid: true,
763
+ customerId: data.customerId,
764
+ email: data.email,
765
+ sessionToken: data.sessionToken,
766
+ tenantId,
767
+ organizationId,
768
+ orgRole,
769
+ redirectUrl: data.redirectUrl
770
+ };
771
+ }
772
+ /**
773
+ * Get the current session for an authenticated customer.
774
+ * Use this to validate session tokens and get customer data.
775
+ *
776
+ * Returns session info including tenant and organization context extracted from the JWT.
777
+ *
778
+ * @example
779
+ * ```typescript
780
+ * // Validate session on each request
781
+ * const sessionToken = req.cookies.session;
782
+ *
783
+ * const session = await stackbe.auth.getSession(sessionToken);
784
+ *
785
+ * if (session) {
786
+ * console.log(session.customerId);
787
+ * console.log(session.tenantId); // Tenant context
788
+ * console.log(session.organizationId); // Org context (if applicable)
789
+ * console.log(session.subscription);
790
+ * console.log(session.entitlements);
791
+ * }
792
+ * ```
793
+ */
794
+ async getSession(sessionToken) {
795
+ try {
796
+ const response = await fetch(
797
+ `${this.http.baseUrl}/v1/apps/${this.appId}/auth/session`,
798
+ {
799
+ headers: {
800
+ "Authorization": `Bearer ${sessionToken}`,
801
+ "Content-Type": "application/json"
802
+ }
803
+ }
804
+ );
805
+ if (!response.ok) {
806
+ if (response.status === 401) {
807
+ return null;
808
+ }
809
+ throw new StackBEError("Failed to get session", response.status, "SESSION_INVALID");
810
+ }
811
+ const data = await response.json();
812
+ let tenantId;
813
+ let organizationId;
814
+ let orgRole;
815
+ const payload = decodeJwt(sessionToken);
816
+ if (payload) {
817
+ tenantId = payload.tenantId;
818
+ organizationId = payload.organizationId;
819
+ orgRole = payload.orgRole;
820
+ }
821
+ return {
822
+ valid: data.valid ?? true,
823
+ customerId: data.customerId,
824
+ email: data.email,
825
+ expiresAt: data.expiresAt,
826
+ tenantId: tenantId ?? data.tenantId,
827
+ organizationId: data.organizationId ?? organizationId,
828
+ orgRole: data.orgRole ?? orgRole,
829
+ customer: data.customer,
830
+ subscription: data.subscription,
831
+ entitlements: data.entitlements
832
+ };
833
+ } catch (error) {
834
+ if (error instanceof StackBEError) {
835
+ throw error;
836
+ }
837
+ return null;
838
+ }
839
+ }
840
+ /**
841
+ * Create Express middleware that validates session tokens.
842
+ *
843
+ * @example
844
+ * ```typescript
845
+ * // Protect routes with authentication
846
+ * app.use('/dashboard', stackbe.auth.middleware({
847
+ * getToken: (req) => req.cookies.session,
848
+ * onUnauthenticated: (req, res) => res.redirect('/login'),
849
+ * }));
850
+ *
851
+ * app.get('/dashboard', (req, res) => {
852
+ * // req.customer is available
853
+ * res.json({ email: req.customer.email });
854
+ * });
855
+ * ```
856
+ */
857
+ middleware(options) {
858
+ return async (req, res, next) => {
859
+ const token = options.getToken(req);
860
+ if (!token) {
861
+ if (options.onUnauthenticated) {
862
+ return options.onUnauthenticated(req, res);
863
+ }
864
+ return res.status(401).json({ error: "Not authenticated" });
865
+ }
866
+ const session = await this.getSession(token);
867
+ if (!session) {
868
+ if (options.onUnauthenticated) {
869
+ return options.onUnauthenticated(req, res);
870
+ }
871
+ return res.status(401).json({ error: "Invalid session" });
872
+ }
873
+ req.customer = session.customer;
874
+ req.subscription = session.subscription;
875
+ req.entitlements = session.entitlements;
876
+ next();
877
+ };
878
+ }
879
+ /**
880
+ * Check if a session token is valid.
881
+ *
882
+ * @example
883
+ * ```typescript
884
+ * const isValid = await stackbe.auth.isAuthenticated(sessionToken);
885
+ * ```
886
+ */
887
+ async isAuthenticated(sessionToken) {
888
+ const session = await this.getSession(sessionToken);
889
+ return session !== null;
890
+ }
891
+ };
892
+
382
893
  // src/client.ts
383
894
  var DEFAULT_BASE_URL = "https://api.stackbe.io";
384
895
  var DEFAULT_TIMEOUT = 3e4;
@@ -401,8 +912,18 @@ var StackBE = class {
401
912
  * // Check entitlements
402
913
  * const { hasAccess } = await stackbe.entitlements.check('customer_123', 'premium');
403
914
  *
404
- * // Get customer
405
- * const customer = await stackbe.customers.get('customer_123');
915
+ * // Create checkout session
916
+ * const { url } = await stackbe.checkout.createSession({
917
+ * customer: 'cust_123',
918
+ * planId: 'plan_pro',
919
+ * successUrl: 'https://myapp.com/success',
920
+ * });
921
+ *
922
+ * // Get subscription
923
+ * const subscription = await stackbe.subscriptions.get('cust_123');
924
+ *
925
+ * // Send magic link
926
+ * await stackbe.auth.sendMagicLink('user@example.com');
406
927
  * ```
407
928
  */
408
929
  constructor(config) {
@@ -412,6 +933,7 @@ var StackBE = class {
412
933
  if (!config.appId) {
413
934
  throw new StackBEError("appId is required", 400, "INVALID_CONFIG");
414
935
  }
936
+ this.appId = config.appId;
415
937
  this.http = new HttpClient({
416
938
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
417
939
  apiKey: config.apiKey,
@@ -421,6 +943,9 @@ var StackBE = class {
421
943
  this.usage = new UsageClient(this.http);
422
944
  this.entitlements = new EntitlementsClient(this.http);
423
945
  this.customers = new CustomersClient(this.http);
946
+ this.checkout = new CheckoutClient(this.http, config.appId);
947
+ this.subscriptions = new SubscriptionsClient(this.http);
948
+ this.auth = new AuthClient(this.http, config.appId);
424
949
  }
425
950
  /**
426
951
  * Create a middleware for Express that tracks usage automatically.
@@ -553,9 +1078,12 @@ var StackBE = class {
553
1078
  }
554
1079
  };
555
1080
  export {
1081
+ AuthClient,
1082
+ CheckoutClient,
556
1083
  CustomersClient,
557
1084
  EntitlementsClient,
558
1085
  StackBE,
559
1086
  StackBEError,
1087
+ SubscriptionsClient,
560
1088
  UsageClient
561
1089
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbe/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official JavaScript/TypeScript SDK for StackBE - the billing backend for your side project",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",