@sense-apps/mantle 0.1.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 ADDED
@@ -0,0 +1,123 @@
1
+ # @mantlekit/sdk
2
+
3
+ Backend-only client your Shopify app uses to talk to your Mantlekit deployment. It deliberately mirrors Mantle's `@heymantle/client` (`MantleClient`) — same constructor shape, method names, and `customer.plan` / `customer.subscription` / `customer.features` nesting — so moving an app off Mantle is close to a find-and-replace.
4
+
5
+ **Server-side only.** Your frontend never talks to Mantlekit directly; it talks to your own backend, which uses this client and relays whatever your frontend needs.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @mantlekit/sdk
11
+ # or: npm install @mantlekit/sdk
12
+ ```
13
+
14
+ ## Configure
15
+
16
+ Set two env vars in your app (both server-side):
17
+
18
+ ```bash
19
+ MANTLEKIT_URL=https://your-mantlekit-api.example.com
20
+ MANTLEKIT_API_KEY=mk_... # per-app API key, created in the Mantlekit dashboard under App → API
21
+ ```
22
+
23
+ Then construct with no arguments:
24
+
25
+ ```ts
26
+ import { MantleKitClient } from "@mantlekit/sdk";
27
+
28
+ const admin = new MantleKitClient(); // reads MANTLEKIT_URL + MANTLEKIT_API_KEY
29
+ ```
30
+
31
+ Explicit options always override env — useful for apps configured via a `config.json`, or when one process talks to more than one deployment:
32
+
33
+ ```ts
34
+ const admin = new MantleKitClient({ baseUrl, apiKey });
35
+ ```
36
+
37
+ ## Auth model — two tiers, both server-side
38
+
39
+ | Credential | Where it lives | What it unlocks |
40
+ | ------------------ | ------------------------------------ | ------------------------------------------------------ |
41
+ | `apiKey` | your server's env | `identify()` only |
42
+ | `customerApiToken` | your DB, one per shop (returned by `identify()`) | everything else — `getPlans`, `getCustomer`, `subscribe`, `cancelSubscription` |
43
+
44
+ `customerApiToken` has **no env fallback on purpose**: it is per-shop state, not deployment config. Store it next to the shop row in your own database.
45
+
46
+ ## Usage
47
+
48
+ ### 1. Register the shop after your OAuth callback
49
+
50
+ ```ts
51
+ const admin = new MantleKitClient();
52
+ const { shopId, apiToken } = await admin.identify({
53
+ myshopifyDomain: "cool-store.myshopify.com",
54
+ accessToken, // the offline token your OAuth flow just obtained
55
+ name: shop.name,
56
+ email: shop.email,
57
+ });
58
+ // persist apiToken on your shop record
59
+ ```
60
+
61
+ `identify()` is an upsert — safe to call again on every app load to keep the shop record fresh.
62
+
63
+ ### 2. Per-shop client for everything else
64
+
65
+ ```ts
66
+ const client = new MantleKitClient({ customerApiToken: shop.mantlekitToken });
67
+ ```
68
+
69
+ ### 3. Pricing page
70
+
71
+ ```ts
72
+ const plans = await client.getPlans();
73
+ // ResolvedPlan[]: planId, name, amount (string — money is never a float),
74
+ // currencyCode, trialDays, viaOverride, features
75
+ ```
76
+
77
+ Eligibility (tag rules, per-shop overrides) is resolved server-side by Mantlekit — the list you get back is exactly what this shop may buy.
78
+
79
+ ### 4. Subscribe / upgrade
80
+
81
+ ```ts
82
+ const { confirmationUrl } = await client.subscribe({
83
+ planId,
84
+ returnUrl: `https://your-app.com/billing/confirm?shop=${shop.domain}`,
85
+ });
86
+ // redirect the merchant's browser to confirmationUrl (Shopify approval screen)
87
+ ```
88
+
89
+ The subscription only becomes active after the merchant approves and Shopify's webhook lands back in Mantlekit — don't grant features on redirect alone; check `getCustomer()`.
90
+
91
+ ### 5. Gate features
92
+
93
+ ```ts
94
+ const customer = await client.getCustomer();
95
+ // { id, myshopifyDomain, plan, subscription: { status, trialEndsAt, ... }, features }
96
+
97
+ if (await client.isFeatureEnabled("advanced_reports")) { ... }
98
+ const maxSeats = await client.limitForFeature("team_seats"); // number | null
99
+ ```
100
+
101
+ `isFeatureEnabled` / `limitForFeature` each call `getCustomer()` under the hood — if you need several flags in one request, call `getCustomer()` once and read `customer.features` yourself.
102
+
103
+ ### 6. Cancel
104
+
105
+ ```ts
106
+ await client.cancelSubscription();
107
+ ```
108
+
109
+ ## Errors
110
+
111
+ Every non-2xx response throws `MantleKitError` with `status` and, for validation failures, `userErrors: { field, message }[]`:
112
+
113
+ ```ts
114
+ import { MantleKitError } from "@mantlekit/sdk";
115
+
116
+ try {
117
+ await client.subscribe({ planId, returnUrl });
118
+ } catch (e) {
119
+ if (e instanceof MantleKitError && e.status === 422) {
120
+ console.error(e.userErrors);
121
+ }
122
+ }
123
+ ```
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
+ * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
+ * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
+ * YOUR domain, since you own the whole stack (no third party in the loop).
6
+ * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
+ * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
+ * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
+ * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
+ * so porting off Mantle is close to a drop-in swap.
11
+ *
12
+ * This package has NO frontend/browser usage — every call (including the
13
+ * customerApiToken-scoped ones) is meant to run on your own server. Your
14
+ * frontend never talks to Mantlekit directly; it talks to YOUR backend,
15
+ * which talks to Mantlekit and relays whatever shape your frontend needs.
16
+ *
17
+ * Two-tier auth, both server-side only:
18
+ * - `apiKey` — scopes `identify()`. Belongs in your server's env, never in
19
+ * client-shipped code.
20
+ * - `customerApiToken` — the per-shop credential `identify()` returns.
21
+ * Scopes everything else (getPlans, getCustomer, subscribe,
22
+ * cancelSubscription) to that one shop. Store it in YOUR OWN database
23
+ * next to that shop's row; still server-side only.
24
+ *
25
+ * Configuration comes from env by default — set MANTLEKIT_URL and
26
+ * MANTLEKIT_API_KEY in your app's env and construct with no arguments.
27
+ * Explicit options always win over env (for apps configured via config.json
28
+ * or when talking to more than one deployment).
29
+ *
30
+ * Typical flow, entirely within your app's backend:
31
+ * 1. Your app's OWN OAuth callback finishes and you have a shop + access token.
32
+ * 2. const admin = new MantleKitClient(); // MANTLEKIT_URL + MANTLEKIT_API_KEY from env
33
+ * const { apiToken } = await admin.identify({ myshopifyDomain, accessToken });
34
+ * 3. Store `apiToken` against that shop in your own DB.
35
+ * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
+ * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
+ * const plans = await client.getPlans();
38
+ * const customer = await client.getCustomer(); // { plan, subscription, features }
39
+ * // ...then your backend returns whatever subset of this your frontend needs.
40
+ */
41
+ export type FeatureType = "boolean" | "limit" | "limit_overage" | "string";
42
+ export interface ResolvedFeature {
43
+ key: string;
44
+ name: string;
45
+ type: FeatureType;
46
+ value: boolean | number | string;
47
+ }
48
+ export interface ResolvedPlan {
49
+ planId: string;
50
+ name: string;
51
+ amount: string;
52
+ currencyCode: string;
53
+ trialDays: number;
54
+ viaOverride: boolean;
55
+ features: Record<string, ResolvedFeature>;
56
+ }
57
+ export interface ShopCustomer {
58
+ id: string;
59
+ myshopifyDomain: string;
60
+ name: string | null;
61
+ email: string | null;
62
+ installedAt: string | null;
63
+ plan: {
64
+ id: string;
65
+ name: string;
66
+ } | null;
67
+ subscription: {
68
+ status: string;
69
+ trialEndsAt: string | null;
70
+ currentPeriodEnd: string | null;
71
+ };
72
+ features: Record<string, ResolvedFeature>;
73
+ }
74
+ export interface IdentifyParams {
75
+ myshopifyDomain: string;
76
+ /** Pass this along if your app already obtained one via its own Shopify OAuth. */
77
+ accessToken?: string;
78
+ name?: string;
79
+ email?: string;
80
+ /** Matches MantleClient.identify()'s shape; defaults to "shopify". */
81
+ platform?: string;
82
+ platformId?: string;
83
+ }
84
+ export interface IdentifyResult {
85
+ shopId: string;
86
+ apiToken: string;
87
+ }
88
+ export interface SubscribeParams {
89
+ planId: string;
90
+ /** Where to send the merchant after Shopify's approval screen — your app's own URL. */
91
+ returnUrl: string;
92
+ test?: boolean;
93
+ }
94
+ export interface SubscribeResult {
95
+ subscriptionId: string;
96
+ confirmationUrl: string;
97
+ }
98
+ export interface CancelSubscriptionResult {
99
+ id: string;
100
+ status: string;
101
+ }
102
+ export declare class MantleKitError extends Error {
103
+ status: number;
104
+ userErrors?: Array<{
105
+ field: string[] | null;
106
+ message: string;
107
+ }>;
108
+ constructor(status: number, message: string, userErrors?: Array<{
109
+ field: string[] | null;
110
+ message: string;
111
+ }>);
112
+ }
113
+ export interface MantleKitClientOptions {
114
+ /**
115
+ * Your own Mantlekit API deployment, e.g. "https://billing.yourcompany.com".
116
+ * Falls back to the MANTLEKIT_URL env var.
117
+ */
118
+ baseUrl?: string;
119
+ /**
120
+ * Server-side only — scopes identify(). Never expose this to a browser.
121
+ * Falls back to the MANTLEKIT_API_KEY env var.
122
+ */
123
+ apiKey?: string;
124
+ /**
125
+ * Client-safe, per-shop token from identify() — scopes every other method.
126
+ * No env fallback on purpose: it is per-shop state that lives in your DB,
127
+ * not deployment config.
128
+ */
129
+ customerApiToken?: string;
130
+ }
131
+ export declare class MantleKitClient {
132
+ private baseUrl;
133
+ private apiKey?;
134
+ private customerApiToken?;
135
+ constructor(options?: MantleKitClientOptions);
136
+ /** Register (or re-sync) a shop after your app's own Shopify OAuth completes. Requires apiKey. */
137
+ identify(params: IdentifyParams): Promise<IdentifyResult>;
138
+ /** Plans this shop is eligible for — what your pricing page renders. Requires customerApiToken. */
139
+ getPlans(): Promise<ResolvedPlan[]>;
140
+ /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
141
+ getCustomer(): Promise<ShopCustomer>;
142
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
143
+ isFeatureEnabled(featureKey: string): Promise<boolean>;
144
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
145
+ limitForFeature(featureKey: string): Promise<number | null>;
146
+ /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
147
+ subscribe(params: SubscribeParams): Promise<SubscribeResult>;
148
+ /** Cancel the shop's active subscription. Requires customerApiToken. */
149
+ cancelSubscription(): Promise<CancelSubscriptionResult>;
150
+ private customerHeaders;
151
+ private request;
152
+ }
package/dist/client.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
+ * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
+ * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
+ * YOUR domain, since you own the whole stack (no third party in the loop).
6
+ * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
+ * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
+ * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
+ * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
+ * so porting off Mantle is close to a drop-in swap.
11
+ *
12
+ * This package has NO frontend/browser usage — every call (including the
13
+ * customerApiToken-scoped ones) is meant to run on your own server. Your
14
+ * frontend never talks to Mantlekit directly; it talks to YOUR backend,
15
+ * which talks to Mantlekit and relays whatever shape your frontend needs.
16
+ *
17
+ * Two-tier auth, both server-side only:
18
+ * - `apiKey` — scopes `identify()`. Belongs in your server's env, never in
19
+ * client-shipped code.
20
+ * - `customerApiToken` — the per-shop credential `identify()` returns.
21
+ * Scopes everything else (getPlans, getCustomer, subscribe,
22
+ * cancelSubscription) to that one shop. Store it in YOUR OWN database
23
+ * next to that shop's row; still server-side only.
24
+ *
25
+ * Configuration comes from env by default — set MANTLEKIT_URL and
26
+ * MANTLEKIT_API_KEY in your app's env and construct with no arguments.
27
+ * Explicit options always win over env (for apps configured via config.json
28
+ * or when talking to more than one deployment).
29
+ *
30
+ * Typical flow, entirely within your app's backend:
31
+ * 1. Your app's OWN OAuth callback finishes and you have a shop + access token.
32
+ * 2. const admin = new MantleKitClient(); // MANTLEKIT_URL + MANTLEKIT_API_KEY from env
33
+ * const { apiToken } = await admin.identify({ myshopifyDomain, accessToken });
34
+ * 3. Store `apiToken` against that shop in your own DB.
35
+ * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
+ * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
+ * const plans = await client.getPlans();
38
+ * const customer = await client.getCustomer(); // { plan, subscription, features }
39
+ * // ...then your backend returns whatever subset of this your frontend needs.
40
+ */
41
+ export class MantleKitError extends Error {
42
+ status;
43
+ userErrors;
44
+ constructor(status, message, userErrors) {
45
+ super(message);
46
+ this.name = "MantleKitError";
47
+ this.status = status;
48
+ this.userErrors = userErrors;
49
+ }
50
+ }
51
+ const env = (key) => typeof process !== "undefined" ? process.env?.[key] : undefined;
52
+ export class MantleKitClient {
53
+ baseUrl;
54
+ apiKey;
55
+ customerApiToken;
56
+ constructor(options = {}) {
57
+ const baseUrl = options.baseUrl ?? env("MANTLEKIT_URL");
58
+ if (!baseUrl) {
59
+ throw new Error("MantleKitClient requires a baseUrl — pass { baseUrl } or set the MANTLEKIT_URL env var to your Mantlekit API deployment");
60
+ }
61
+ this.baseUrl = baseUrl.replace(/\/$/, "");
62
+ this.apiKey = options.apiKey ?? env("MANTLEKIT_API_KEY");
63
+ this.customerApiToken = options.customerApiToken;
64
+ }
65
+ /** Register (or re-sync) a shop after your app's own Shopify OAuth completes. Requires apiKey. */
66
+ identify(params) {
67
+ if (!this.apiKey)
68
+ throw new Error("identify() requires apiKey — call this from your server, never a browser");
69
+ return this.request("POST", "/sdk/v1/identify", { "X-Api-Key": this.apiKey }, { platform: "shopify", ...params });
70
+ }
71
+ /** Plans this shop is eligible for — what your pricing page renders. Requires customerApiToken. */
72
+ getPlans() {
73
+ return this.request("GET", "/sdk/v1/plans", this.customerHeaders());
74
+ }
75
+ /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
76
+ getCustomer() {
77
+ return this.request("GET", "/sdk/v1/customer", this.customerHeaders());
78
+ }
79
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
80
+ async isFeatureEnabled(featureKey) {
81
+ const customer = await this.getCustomer();
82
+ return Boolean(customer.features[featureKey]?.value);
83
+ }
84
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
85
+ async limitForFeature(featureKey) {
86
+ const customer = await this.getCustomer();
87
+ const value = customer.features[featureKey]?.value;
88
+ return typeof value === "number" ? value : null;
89
+ }
90
+ /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
91
+ subscribe(params) {
92
+ return this.request("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
93
+ }
94
+ /** Cancel the shop's active subscription. Requires customerApiToken. */
95
+ cancelSubscription() {
96
+ return this.request("POST", "/sdk/v1/cancel-subscription", this.customerHeaders());
97
+ }
98
+ customerHeaders() {
99
+ if (!this.customerApiToken)
100
+ throw new Error("This call requires customerApiToken — obtain one via identify() first");
101
+ return { "X-Customer-Api-Token": this.customerApiToken };
102
+ }
103
+ async request(method, path, headers, body) {
104
+ const res = await fetch(`${this.baseUrl}${path}`, {
105
+ method,
106
+ headers: { "Content-Type": "application/json", ...headers },
107
+ body: body !== undefined ? JSON.stringify(body) : undefined,
108
+ });
109
+ const json = (await res.json().catch(() => null));
110
+ if (!res.ok) {
111
+ throw new MantleKitError(res.status, json?.error ?? `Request failed: ${res.status}`, json?.userErrors);
112
+ }
113
+ return json;
114
+ }
115
+ }
@@ -0,0 +1 @@
1
+ export * from "./client";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./client";
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@sense-apps/mantle",
3
+ "version": "0.1.0",
4
+ "description": "Backend-only client for a self-hosted Mantlekit deployment — MantleClient-compatible API (identify, getPlans, getCustomer, subscribe) for Shopify apps.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "bun": "./src/index.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "src", "README.md"],
16
+ "sideEffects": false,
17
+ "license": "UNLICENSED",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit",
23
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
24
+ "prepublishOnly": "bun run typecheck && bun run build"
25
+ },
26
+ "devDependencies": {
27
+ "bun-types": "latest",
28
+ "typescript": "^5"
29
+ }
30
+ }
package/src/client.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
+ * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
+ * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
+ * YOUR domain, since you own the whole stack (no third party in the loop).
6
+ * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
+ * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
+ * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
+ * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
+ * so porting off Mantle is close to a drop-in swap.
11
+ *
12
+ * This package has NO frontend/browser usage — every call (including the
13
+ * customerApiToken-scoped ones) is meant to run on your own server. Your
14
+ * frontend never talks to Mantlekit directly; it talks to YOUR backend,
15
+ * which talks to Mantlekit and relays whatever shape your frontend needs.
16
+ *
17
+ * Two-tier auth, both server-side only:
18
+ * - `apiKey` — scopes `identify()`. Belongs in your server's env, never in
19
+ * client-shipped code.
20
+ * - `customerApiToken` — the per-shop credential `identify()` returns.
21
+ * Scopes everything else (getPlans, getCustomer, subscribe,
22
+ * cancelSubscription) to that one shop. Store it in YOUR OWN database
23
+ * next to that shop's row; still server-side only.
24
+ *
25
+ * Configuration comes from env by default — set MANTLEKIT_URL and
26
+ * MANTLEKIT_API_KEY in your app's env and construct with no arguments.
27
+ * Explicit options always win over env (for apps configured via config.json
28
+ * or when talking to more than one deployment).
29
+ *
30
+ * Typical flow, entirely within your app's backend:
31
+ * 1. Your app's OWN OAuth callback finishes and you have a shop + access token.
32
+ * 2. const admin = new MantleKitClient(); // MANTLEKIT_URL + MANTLEKIT_API_KEY from env
33
+ * const { apiToken } = await admin.identify({ myshopifyDomain, accessToken });
34
+ * 3. Store `apiToken` against that shop in your own DB.
35
+ * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
+ * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
+ * const plans = await client.getPlans();
38
+ * const customer = await client.getCustomer(); // { plan, subscription, features }
39
+ * // ...then your backend returns whatever subset of this your frontend needs.
40
+ */
41
+
42
+ export type FeatureType = "boolean" | "limit" | "limit_overage" | "string";
43
+
44
+ export interface ResolvedFeature {
45
+ key: string;
46
+ name: string;
47
+ type: FeatureType;
48
+ value: boolean | number | string;
49
+ }
50
+
51
+ export interface ResolvedPlan {
52
+ planId: string;
53
+ name: string;
54
+ amount: string;
55
+ currencyCode: string;
56
+ trialDays: number;
57
+ viaOverride: boolean;
58
+ features: Record<string, ResolvedFeature>;
59
+ }
60
+
61
+ export interface ShopCustomer {
62
+ id: string;
63
+ myshopifyDomain: string;
64
+ name: string | null;
65
+ email: string | null;
66
+ installedAt: string | null;
67
+ plan: { id: string; name: string } | null;
68
+ subscription: { status: string; trialEndsAt: string | null; currentPeriodEnd: string | null };
69
+ features: Record<string, ResolvedFeature>;
70
+ }
71
+
72
+ export interface IdentifyParams {
73
+ myshopifyDomain: string;
74
+ /** Pass this along if your app already obtained one via its own Shopify OAuth. */
75
+ accessToken?: string;
76
+ name?: string;
77
+ email?: string;
78
+ /** Matches MantleClient.identify()'s shape; defaults to "shopify". */
79
+ platform?: string;
80
+ platformId?: string;
81
+ }
82
+
83
+ export interface IdentifyResult {
84
+ shopId: string;
85
+ apiToken: string;
86
+ }
87
+
88
+ export interface SubscribeParams {
89
+ planId: string;
90
+ /** Where to send the merchant after Shopify's approval screen — your app's own URL. */
91
+ returnUrl: string;
92
+ test?: boolean;
93
+ }
94
+
95
+ export interface SubscribeResult {
96
+ subscriptionId: string;
97
+ confirmationUrl: string;
98
+ }
99
+
100
+ export interface CancelSubscriptionResult {
101
+ id: string;
102
+ status: string;
103
+ }
104
+
105
+ export class MantleKitError extends Error {
106
+ status: number;
107
+ userErrors?: Array<{ field: string[] | null; message: string }>;
108
+ constructor(status: number, message: string, userErrors?: Array<{ field: string[] | null; message: string }>) {
109
+ super(message);
110
+ this.name = "MantleKitError";
111
+ this.status = status;
112
+ this.userErrors = userErrors;
113
+ }
114
+ }
115
+
116
+ export interface MantleKitClientOptions {
117
+ /**
118
+ * Your own Mantlekit API deployment, e.g. "https://billing.yourcompany.com".
119
+ * Falls back to the MANTLEKIT_URL env var.
120
+ */
121
+ baseUrl?: string;
122
+ /**
123
+ * Server-side only — scopes identify(). Never expose this to a browser.
124
+ * Falls back to the MANTLEKIT_API_KEY env var.
125
+ */
126
+ apiKey?: string;
127
+ /**
128
+ * Client-safe, per-shop token from identify() — scopes every other method.
129
+ * No env fallback on purpose: it is per-shop state that lives in your DB,
130
+ * not deployment config.
131
+ */
132
+ customerApiToken?: string;
133
+ }
134
+
135
+ const env = (key: string): string | undefined =>
136
+ typeof process !== "undefined" ? process.env?.[key] : undefined;
137
+
138
+ export class MantleKitClient {
139
+ private baseUrl: string;
140
+ private apiKey?: string;
141
+ private customerApiToken?: string;
142
+
143
+ constructor(options: MantleKitClientOptions = {}) {
144
+ const baseUrl = options.baseUrl ?? env("MANTLEKIT_URL");
145
+ if (!baseUrl) {
146
+ throw new Error(
147
+ "MantleKitClient requires a baseUrl — pass { baseUrl } or set the MANTLEKIT_URL env var to your Mantlekit API deployment"
148
+ );
149
+ }
150
+ this.baseUrl = baseUrl.replace(/\/$/, "");
151
+ this.apiKey = options.apiKey ?? env("MANTLEKIT_API_KEY");
152
+ this.customerApiToken = options.customerApiToken;
153
+ }
154
+
155
+ /** Register (or re-sync) a shop after your app's own Shopify OAuth completes. Requires apiKey. */
156
+ identify(params: IdentifyParams): Promise<IdentifyResult> {
157
+ if (!this.apiKey) throw new Error("identify() requires apiKey — call this from your server, never a browser");
158
+ return this.request<IdentifyResult>("POST", "/sdk/v1/identify", { "X-Api-Key": this.apiKey }, { platform: "shopify", ...params });
159
+ }
160
+
161
+ /** Plans this shop is eligible for — what your pricing page renders. Requires customerApiToken. */
162
+ getPlans(): Promise<ResolvedPlan[]> {
163
+ return this.request<ResolvedPlan[]>("GET", "/sdk/v1/plans", this.customerHeaders());
164
+ }
165
+
166
+ /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
167
+ getCustomer(): Promise<ShopCustomer> {
168
+ return this.request<ShopCustomer>("GET", "/sdk/v1/customer", this.customerHeaders());
169
+ }
170
+
171
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
172
+ async isFeatureEnabled(featureKey: string): Promise<boolean> {
173
+ const customer = await this.getCustomer();
174
+ return Boolean(customer.features[featureKey]?.value);
175
+ }
176
+
177
+ /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
178
+ async limitForFeature(featureKey: string): Promise<number | null> {
179
+ const customer = await this.getCustomer();
180
+ const value = customer.features[featureKey]?.value;
181
+ return typeof value === "number" ? value : null;
182
+ }
183
+
184
+ /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
185
+ subscribe(params: SubscribeParams): Promise<SubscribeResult> {
186
+ return this.request<SubscribeResult>("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
187
+ }
188
+
189
+ /** Cancel the shop's active subscription. Requires customerApiToken. */
190
+ cancelSubscription(): Promise<CancelSubscriptionResult> {
191
+ return this.request<CancelSubscriptionResult>("POST", "/sdk/v1/cancel-subscription", this.customerHeaders());
192
+ }
193
+
194
+ private customerHeaders(): Record<string, string> {
195
+ if (!this.customerApiToken) throw new Error("This call requires customerApiToken — obtain one via identify() first");
196
+ return { "X-Customer-Api-Token": this.customerApiToken };
197
+ }
198
+
199
+ private async request<T>(method: string, path: string, headers: Record<string, string>, body?: unknown): Promise<T> {
200
+ const res = await fetch(`${this.baseUrl}${path}`, {
201
+ method,
202
+ headers: { "Content-Type": "application/json", ...headers },
203
+ body: body !== undefined ? JSON.stringify(body) : undefined,
204
+ });
205
+ const json = (await res.json().catch(() => null)) as
206
+ | { error?: string; userErrors?: Array<{ field: string[] | null; message: string }> }
207
+ | null;
208
+ if (!res.ok) {
209
+ throw new MantleKitError(res.status, json?.error ?? `Request failed: ${res.status}`, json?.userErrors);
210
+ }
211
+ return json as T;
212
+ }
213
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./client";