@skalpgg/sdk 1.0.0 → 1.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 +14 -1
- package/dist/cjs/index.d.ts +14 -7
- package/dist/cjs/index.js +79 -44
- package/dist/cjs/types.d.ts +13 -5
- package/dist/esm/index.d.ts +14 -7
- package/dist/esm/index.js +77 -43
- package/dist/esm/types.d.ts +13 -5
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Create the key on the API keys page of your dashboard. Each key is bound to one
|
|
|
49
49
|
| `subscriptions.getCheckout(id)` | `checkout:read` |
|
|
50
50
|
| `subscriptions.list({ externalRef?, status?, updatedSince?, limit?, offset? })` | `subscriptions:read` |
|
|
51
51
|
| `subscriptions.get(id)` | `subscriptions:read` |
|
|
52
|
+
| `subscriptions.changePlan(id, { productId \| productSlug })` | `subscriptions:write` |
|
|
52
53
|
| `subscriptions.cancel(id)` | `subscriptions:write` |
|
|
53
54
|
| `subscriptions.listInvoices(id)` | `subscriptions:read` |
|
|
54
55
|
| `licenses.verify({ licenseKey, serverIp, serverKey, serverPort? })` | none |
|
|
@@ -76,6 +77,18 @@ A webhook with the default events receives every purchase as both `order.paid` a
|
|
|
76
77
|
|
|
77
78
|
## Errors
|
|
78
79
|
|
|
79
|
-
API
|
|
80
|
+
A refusal from the API throws `SkalpError` with `status`, `code` (for example `STORE_REQUIRED`) and the parsed `body`. A network failure or a timeout throws `SkalpConnectionError`, naming the call and keeping the original in `cause`, so the two are easy to tell apart:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
try {
|
|
84
|
+
await skalp.orders.get(id)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof SkalpError) return reportRefusal(error.code, error.status)
|
|
87
|
+
if (error instanceof SkalpConnectionError) return retryLater()
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A `429` is retried up to `maxRetries` times (default 2) when the limit resets within 10 seconds. `constructEvent` throws `SkalpSignatureError` with `reason: 'stale' | 'invalid'` — `'stale'` means the delivery is outside the 5 minute replay window, `'invalid'` means the bytes or the secret do not match.
|
|
80
93
|
|
|
81
94
|
The constructor throws if it finds an API key in a browser, so the mistake shows up the first time you run the code rather than after a secret key has shipped to users. Pass `dangerouslyAllowBrowser: true` to override it.
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CheckoutSession, CheckoutSessionStatus, CreateCheckoutParams, CreateSubscriptionCheckoutParams, HeartbeatParams, Invoice, LicenseVerification, ListSubscriptionsParams, Order, Product, SkalpEvent, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, VerifyLicenseParams } from './types.js';
|
|
1
|
+
import type { ChangeSubscriptionPlanParams, CheckoutSession, CheckoutSessionStatus, CreateCheckoutParams, CreateSubscriptionCheckoutParams, HeartbeatParams, Invoice, LicenseVerification, ListSubscriptionsParams, Order, Product, SkalpEvent, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, VerifyLicenseParams } from './types.js';
|
|
2
2
|
export type * from './types.js';
|
|
3
|
+
export declare const VERSION = "1.1.0";
|
|
3
4
|
export interface SkalpOptions {
|
|
4
5
|
apiKey?: string;
|
|
5
6
|
webhookSecret?: string;
|
|
@@ -19,14 +20,19 @@ export declare class SkalpError extends Error {
|
|
|
19
20
|
readonly body: unknown;
|
|
20
21
|
constructor(message: string, status: number, code?: string, body?: unknown);
|
|
21
22
|
}
|
|
23
|
+
export declare class SkalpConnectionError extends Error {
|
|
24
|
+
constructor(method: string, path: string, cause: unknown);
|
|
25
|
+
}
|
|
26
|
+
export type SignatureFailure = 'stale' | 'invalid';
|
|
22
27
|
export declare class SkalpSignatureError extends Error {
|
|
23
|
-
|
|
28
|
+
readonly reason: SignatureFailure;
|
|
29
|
+
constructor(reason?: SignatureFailure);
|
|
24
30
|
}
|
|
25
31
|
export declare class Skalp {
|
|
26
32
|
#private;
|
|
27
33
|
constructor(options?: string | SkalpOptions);
|
|
28
34
|
readonly checkout: {
|
|
29
|
-
create: (
|
|
35
|
+
create: ({ productId, customerEmail, metadata, couponCode, returnUrl }: CreateCheckoutParams) => Promise<CheckoutSession>;
|
|
30
36
|
get: (id: string) => Promise<CheckoutSessionStatus>;
|
|
31
37
|
};
|
|
32
38
|
readonly orders: {
|
|
@@ -36,16 +42,17 @@ export declare class Skalp {
|
|
|
36
42
|
list: () => Promise<Product[]>;
|
|
37
43
|
};
|
|
38
44
|
readonly subscriptions: {
|
|
39
|
-
createCheckout: (
|
|
45
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
|
|
40
46
|
getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
|
|
41
|
-
list: (
|
|
47
|
+
list: ({ externalRef, status, updatedSince, limit, offset }?: ListSubscriptionsParams) => Promise<SubscriptionList>;
|
|
42
48
|
get: (id: string) => Promise<Subscription>;
|
|
49
|
+
changePlan: (id: string, { productId, productSlug }: ChangeSubscriptionPlanParams) => Promise<Subscription>;
|
|
43
50
|
cancel: (id: string) => Promise<Subscription>;
|
|
44
51
|
listInvoices: (id: string) => Promise<Invoice[]>;
|
|
45
52
|
};
|
|
46
53
|
readonly licenses: {
|
|
47
|
-
verify: (
|
|
48
|
-
heartbeat: (
|
|
54
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }: VerifyLicenseParams) => Promise<LicenseVerification>;
|
|
55
|
+
heartbeat: ({ licenseKey, serverIp }: HeartbeatParams) => Promise<{
|
|
49
56
|
valid: boolean;
|
|
50
57
|
}>;
|
|
51
58
|
};
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Skalp = exports.SkalpSignatureError = exports.SkalpError = void 0;
|
|
3
|
+
exports.Skalp = exports.SkalpSignatureError = exports.SkalpConnectionError = exports.SkalpError = exports.VERSION = void 0;
|
|
4
|
+
exports.VERSION = '1.1.0';
|
|
4
5
|
const SIGNATURE_HEADER = 'skalp-signature';
|
|
5
6
|
const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
7
|
+
const USER_AGENT = `skalp-node/${exports.VERSION}`;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
const MAX_RETRY_WAIT_SECONDS = 10;
|
|
6
10
|
class SkalpError extends Error {
|
|
7
11
|
status;
|
|
8
12
|
code;
|
|
@@ -16,16 +20,26 @@ class SkalpError extends Error {
|
|
|
16
20
|
}
|
|
17
21
|
}
|
|
18
22
|
exports.SkalpError = SkalpError;
|
|
23
|
+
class SkalpConnectionError extends Error {
|
|
24
|
+
constructor(method, path, cause) {
|
|
25
|
+
super(`Skalp: ${method} ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
26
|
+
this.name = 'SkalpConnectionError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.SkalpConnectionError = SkalpConnectionError;
|
|
19
30
|
class SkalpSignatureError extends Error {
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
reason;
|
|
32
|
+
constructor(reason = 'invalid') {
|
|
33
|
+
super(reason === 'stale'
|
|
34
|
+
? 'Skalp: webhook signature is outside the replay window'
|
|
35
|
+
: 'Skalp: invalid webhook signature');
|
|
22
36
|
this.name = 'SkalpSignatureError';
|
|
37
|
+
this.reason = reason;
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
exports.SkalpSignatureError = SkalpSignatureError;
|
|
26
41
|
const env = (name) => globalThis.process?.env?.[name];
|
|
27
42
|
const segment = (id) => encodeURIComponent(id);
|
|
28
|
-
const MAX_RETRY_WAIT_SECONDS = 10;
|
|
29
43
|
class Skalp {
|
|
30
44
|
#apiKey;
|
|
31
45
|
#webhookSecret;
|
|
@@ -38,7 +52,7 @@ class Skalp {
|
|
|
38
52
|
this.#apiKey = o.apiKey ?? env('SKALP_API_KEY');
|
|
39
53
|
this.#webhookSecret = o.webhookSecret ?? env('SKALP_WEBHOOK_SECRET');
|
|
40
54
|
this.#baseUrl = (o.baseUrl ?? env('SKALP_API_URL') ?? 'https://api.skalp.gg').replace(/\/+$/, '');
|
|
41
|
-
this.#timeout = o.timeout
|
|
55
|
+
this.#timeout = o.timeout && o.timeout > 0 ? o.timeout : DEFAULT_TIMEOUT_MS;
|
|
42
56
|
this.#maxRetries = o.maxRetries ?? 2;
|
|
43
57
|
this.#fetch = o.fetch ?? ((...args) => fetch(...args));
|
|
44
58
|
if (this.#apiKey && 'window' in globalThis && 'document' in globalThis && !o.dangerouslyAllowBrowser) {
|
|
@@ -46,12 +60,9 @@ class Skalp {
|
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
checkout = {
|
|
49
|
-
create: (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
body: { items: [{ productId, quantity: 1 }], ...rest },
|
|
53
|
-
});
|
|
54
|
-
},
|
|
63
|
+
create: ({ productId, customerEmail, metadata, couponCode, returnUrl }) => this.#request('POST', '/v1/checkout/sessions', {
|
|
64
|
+
body: { items: [{ productId, quantity: 1 }], customerEmail, metadata, couponCode, returnUrl },
|
|
65
|
+
}),
|
|
55
66
|
get: (id) => this.#request('GET', `/v1/checkout/sessions/${segment(id)}`),
|
|
56
67
|
};
|
|
57
68
|
orders = {
|
|
@@ -61,47 +72,65 @@ class Skalp {
|
|
|
61
72
|
list: () => this.#request('GET', '/v1/products'),
|
|
62
73
|
};
|
|
63
74
|
subscriptions = {
|
|
64
|
-
createCheckout: (
|
|
75
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }) => this.#request('POST', '/v1/subscriptions/checkout', {
|
|
76
|
+
body: { productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl },
|
|
77
|
+
}),
|
|
65
78
|
getCheckout: (id) => this.#request('GET', `/v1/subscriptions/checkout/${segment(id)}`),
|
|
66
|
-
list: (
|
|
79
|
+
list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
|
|
67
80
|
query: {
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
externalRef,
|
|
82
|
+
status,
|
|
83
|
+
updatedSince: updatedSince instanceof Date ? updatedSince.toISOString() : updatedSince,
|
|
84
|
+
limit,
|
|
85
|
+
offset,
|
|
70
86
|
},
|
|
71
87
|
}),
|
|
72
88
|
get: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}`),
|
|
89
|
+
changePlan: (id, { productId, productSlug }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/plan`, {
|
|
90
|
+
body: { productId, productSlug },
|
|
91
|
+
}),
|
|
73
92
|
cancel: (id) => this.#request('DELETE', `/v1/subscriptions/${segment(id)}`),
|
|
74
93
|
listInvoices: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}/invoices`).then((r) => r.data),
|
|
75
94
|
};
|
|
76
95
|
licenses = {
|
|
77
|
-
verify: (
|
|
78
|
-
|
|
96
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }) => this.#request('POST', '/licenses/verify', {
|
|
97
|
+
body: { licenseKey, serverIp, serverKey, serverPort },
|
|
98
|
+
auth: false,
|
|
99
|
+
}),
|
|
100
|
+
heartbeat: ({ licenseKey, serverIp }) => this.#request('POST', '/licenses/heartbeat', {
|
|
101
|
+
body: { licenseKey, serverIp },
|
|
102
|
+
auth: false,
|
|
103
|
+
}),
|
|
79
104
|
};
|
|
80
105
|
webhooks = {
|
|
81
|
-
verify: async (payload, signature, secret) =>
|
|
82
|
-
const key = secret ?? this.#webhookSecret;
|
|
83
|
-
if (!key) {
|
|
84
|
-
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
85
|
-
}
|
|
86
|
-
const encoder = new TextEncoder();
|
|
87
|
-
const body = toBytes(payload, encoder);
|
|
88
|
-
const parsed = parseSignature(readSignature(signature));
|
|
89
|
-
if (!parsed || Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
90
|
-
return false;
|
|
91
|
-
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
92
|
-
const signed = new Uint8Array(prefix.length + body.length);
|
|
93
|
-
signed.set(prefix);
|
|
94
|
-
signed.set(body, prefix.length);
|
|
95
|
-
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
96
|
-
return crypto.subtle.verify('HMAC', hmac, parsed.signature, signed);
|
|
97
|
-
},
|
|
106
|
+
verify: async (payload, signature, secret) => (await this.#checkSignature(payload, signature, secret)) === undefined,
|
|
98
107
|
constructEvent: async (payload, signature, secret) => {
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
const failure = await this.#checkSignature(payload, signature, secret);
|
|
109
|
+
if (failure)
|
|
110
|
+
throw new SkalpSignatureError(failure);
|
|
101
111
|
const text = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
|
|
102
112
|
return JSON.parse(text);
|
|
103
113
|
},
|
|
104
114
|
};
|
|
115
|
+
async #checkSignature(payload, signature, secret) {
|
|
116
|
+
const key = secret ?? this.#webhookSecret;
|
|
117
|
+
if (!key) {
|
|
118
|
+
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
119
|
+
}
|
|
120
|
+
const encoder = new TextEncoder();
|
|
121
|
+
const body = toBytes(payload, encoder);
|
|
122
|
+
const parsed = parseSignature(readSignature(signature));
|
|
123
|
+
if (!parsed)
|
|
124
|
+
return 'invalid';
|
|
125
|
+
if (Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
126
|
+
return 'stale';
|
|
127
|
+
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
128
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
129
|
+
signed.set(prefix);
|
|
130
|
+
signed.set(body, prefix.length);
|
|
131
|
+
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
132
|
+
return (await crypto.subtle.verify('HMAC', hmac, parsed.signature, signed)) ? undefined : 'invalid';
|
|
133
|
+
}
|
|
105
134
|
async #request(method, path, { body, query, auth = true } = {}) {
|
|
106
135
|
if (auth && !this.#apiKey) {
|
|
107
136
|
throw new Error('Skalp: missing API key. Pass it to new Skalp(apiKey) or set SKALP_API_KEY.');
|
|
@@ -111,18 +140,24 @@ class Skalp {
|
|
|
111
140
|
if (value !== undefined && value !== null)
|
|
112
141
|
url.searchParams.set(name, String(value));
|
|
113
142
|
}
|
|
114
|
-
const headers = { accept: 'application/json' };
|
|
143
|
+
const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
|
|
115
144
|
if (auth)
|
|
116
145
|
headers['x-api-key'] = this.#apiKey;
|
|
117
146
|
if (body !== undefined)
|
|
118
147
|
headers['content-type'] = 'application/json';
|
|
119
148
|
for (let attempt = 0;; attempt++) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
let res;
|
|
150
|
+
try {
|
|
151
|
+
res = await this.#fetch(url, {
|
|
152
|
+
method,
|
|
153
|
+
headers,
|
|
154
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
155
|
+
signal: AbortSignal.timeout(this.#timeout),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
throw new SkalpConnectionError(method, path, cause);
|
|
160
|
+
}
|
|
126
161
|
if (res.status === 429 && attempt < this.#maxRetries) {
|
|
127
162
|
const wait = retryAfterSeconds(res, attempt);
|
|
128
163
|
if (wait <= MAX_RETRY_WAIT_SECONDS) {
|
|
@@ -188,7 +223,7 @@ function retryAfterSeconds(res, attempt) {
|
|
|
188
223
|
const reset = Number(res.headers.get('x-ratelimit-reset'));
|
|
189
224
|
if (reset > 0)
|
|
190
225
|
return Math.max(reset - Date.now() / 1000, 0.5);
|
|
191
|
-
return 2 ** attempt;
|
|
226
|
+
return 2 ** Math.min(attempt, 5);
|
|
192
227
|
}
|
|
193
228
|
function toError(status, body) {
|
|
194
229
|
const b = (body && typeof body === 'object' ? body : {});
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -101,6 +101,10 @@ export interface Subscription {
|
|
|
101
101
|
createdAt: string;
|
|
102
102
|
updatedAt: string;
|
|
103
103
|
}
|
|
104
|
+
export interface ChangeSubscriptionPlanParams {
|
|
105
|
+
productId?: string;
|
|
106
|
+
productSlug?: string;
|
|
107
|
+
}
|
|
104
108
|
export interface ListSubscriptionsParams {
|
|
105
109
|
externalRef?: string;
|
|
106
110
|
status?: SubscriptionStatus;
|
|
@@ -151,7 +155,7 @@ export interface HeartbeatParams {
|
|
|
151
155
|
licenseKey: string;
|
|
152
156
|
serverIp: string;
|
|
153
157
|
}
|
|
154
|
-
interface
|
|
158
|
+
interface Envelope<TType extends string, TData> {
|
|
155
159
|
type: TType;
|
|
156
160
|
timestamp: string;
|
|
157
161
|
data: TData;
|
|
@@ -194,6 +198,10 @@ export interface OrderRefundedData {
|
|
|
194
198
|
refundId: string;
|
|
195
199
|
amount: number;
|
|
196
200
|
reason: string | null;
|
|
201
|
+
identifier: string | null;
|
|
202
|
+
steamHex: string | null;
|
|
203
|
+
coins?: number;
|
|
204
|
+
giftRecipient: boolean;
|
|
197
205
|
}
|
|
198
206
|
export interface OrderDisputedData {
|
|
199
207
|
orderId: string;
|
|
@@ -236,17 +244,17 @@ export interface WebhookTestData {
|
|
|
236
244
|
webhookId: string;
|
|
237
245
|
sample: Partial<OrderPaidData>;
|
|
238
246
|
}
|
|
239
|
-
export type SkalpEvent =
|
|
247
|
+
export type SkalpEvent = Envelope<'order.paid' | 'purchase.completed', OrderPaidData> | Envelope<'order.refunded', OrderRefundedData> | Envelope<'order.disputed' | 'account.suspend', OrderDisputedData> | Envelope<'account.restore', AccountRestoreData> | Envelope<'subscription.created' | 'subscription.updated', SubscriptionEventData> | Envelope<'subscription.renewed', SubscriptionEventData & {
|
|
240
248
|
invoiceId: string;
|
|
241
249
|
amountPaid: number;
|
|
242
250
|
periodExtendedTo: string;
|
|
243
|
-
}> |
|
|
251
|
+
}> | Envelope<'subscription.past_due', SubscriptionEventData & {
|
|
244
252
|
invoiceId: string;
|
|
245
253
|
attemptCount: number;
|
|
246
254
|
nextPaymentAttempt: string | null;
|
|
247
|
-
}> |
|
|
255
|
+
}> | Envelope<'subscription.canceled', SubscriptionEventData & {
|
|
248
256
|
endedAt: string | null;
|
|
249
257
|
cancelReason: 'requested' | 'payment_failure' | 'stripe';
|
|
250
|
-
}> |
|
|
258
|
+
}> | Envelope<'webhook.test', WebhookTestData>;
|
|
251
259
|
export type SkalpEventType = SkalpEvent['type'];
|
|
252
260
|
export {};
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CheckoutSession, CheckoutSessionStatus, CreateCheckoutParams, CreateSubscriptionCheckoutParams, HeartbeatParams, Invoice, LicenseVerification, ListSubscriptionsParams, Order, Product, SkalpEvent, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, VerifyLicenseParams } from './types.js';
|
|
1
|
+
import type { ChangeSubscriptionPlanParams, CheckoutSession, CheckoutSessionStatus, CreateCheckoutParams, CreateSubscriptionCheckoutParams, HeartbeatParams, Invoice, LicenseVerification, ListSubscriptionsParams, Order, Product, SkalpEvent, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, VerifyLicenseParams } from './types.js';
|
|
2
2
|
export type * from './types.js';
|
|
3
|
+
export declare const VERSION = "1.1.0";
|
|
3
4
|
export interface SkalpOptions {
|
|
4
5
|
apiKey?: string;
|
|
5
6
|
webhookSecret?: string;
|
|
@@ -19,14 +20,19 @@ export declare class SkalpError extends Error {
|
|
|
19
20
|
readonly body: unknown;
|
|
20
21
|
constructor(message: string, status: number, code?: string, body?: unknown);
|
|
21
22
|
}
|
|
23
|
+
export declare class SkalpConnectionError extends Error {
|
|
24
|
+
constructor(method: string, path: string, cause: unknown);
|
|
25
|
+
}
|
|
26
|
+
export type SignatureFailure = 'stale' | 'invalid';
|
|
22
27
|
export declare class SkalpSignatureError extends Error {
|
|
23
|
-
|
|
28
|
+
readonly reason: SignatureFailure;
|
|
29
|
+
constructor(reason?: SignatureFailure);
|
|
24
30
|
}
|
|
25
31
|
export declare class Skalp {
|
|
26
32
|
#private;
|
|
27
33
|
constructor(options?: string | SkalpOptions);
|
|
28
34
|
readonly checkout: {
|
|
29
|
-
create: (
|
|
35
|
+
create: ({ productId, customerEmail, metadata, couponCode, returnUrl }: CreateCheckoutParams) => Promise<CheckoutSession>;
|
|
30
36
|
get: (id: string) => Promise<CheckoutSessionStatus>;
|
|
31
37
|
};
|
|
32
38
|
readonly orders: {
|
|
@@ -36,16 +42,17 @@ export declare class Skalp {
|
|
|
36
42
|
list: () => Promise<Product[]>;
|
|
37
43
|
};
|
|
38
44
|
readonly subscriptions: {
|
|
39
|
-
createCheckout: (
|
|
45
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
|
|
40
46
|
getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
|
|
41
|
-
list: (
|
|
47
|
+
list: ({ externalRef, status, updatedSince, limit, offset }?: ListSubscriptionsParams) => Promise<SubscriptionList>;
|
|
42
48
|
get: (id: string) => Promise<Subscription>;
|
|
49
|
+
changePlan: (id: string, { productId, productSlug }: ChangeSubscriptionPlanParams) => Promise<Subscription>;
|
|
43
50
|
cancel: (id: string) => Promise<Subscription>;
|
|
44
51
|
listInvoices: (id: string) => Promise<Invoice[]>;
|
|
45
52
|
};
|
|
46
53
|
readonly licenses: {
|
|
47
|
-
verify: (
|
|
48
|
-
heartbeat: (
|
|
54
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }: VerifyLicenseParams) => Promise<LicenseVerification>;
|
|
55
|
+
heartbeat: ({ licenseKey, serverIp }: HeartbeatParams) => Promise<{
|
|
49
56
|
valid: boolean;
|
|
50
57
|
}>;
|
|
51
58
|
};
|
package/dist/esm/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
export const VERSION = '1.1.0';
|
|
1
2
|
const SIGNATURE_HEADER = 'skalp-signature';
|
|
2
3
|
const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
4
|
+
const USER_AGENT = `skalp-node/${VERSION}`;
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
const MAX_RETRY_WAIT_SECONDS = 10;
|
|
3
7
|
export class SkalpError extends Error {
|
|
4
8
|
status;
|
|
5
9
|
code;
|
|
@@ -12,15 +16,24 @@ export class SkalpError extends Error {
|
|
|
12
16
|
this.body = body;
|
|
13
17
|
}
|
|
14
18
|
}
|
|
19
|
+
export class SkalpConnectionError extends Error {
|
|
20
|
+
constructor(method, path, cause) {
|
|
21
|
+
super(`Skalp: ${method} ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
22
|
+
this.name = 'SkalpConnectionError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
export class SkalpSignatureError extends Error {
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
reason;
|
|
27
|
+
constructor(reason = 'invalid') {
|
|
28
|
+
super(reason === 'stale'
|
|
29
|
+
? 'Skalp: webhook signature is outside the replay window'
|
|
30
|
+
: 'Skalp: invalid webhook signature');
|
|
18
31
|
this.name = 'SkalpSignatureError';
|
|
32
|
+
this.reason = reason;
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
35
|
const env = (name) => globalThis.process?.env?.[name];
|
|
22
36
|
const segment = (id) => encodeURIComponent(id);
|
|
23
|
-
const MAX_RETRY_WAIT_SECONDS = 10;
|
|
24
37
|
export class Skalp {
|
|
25
38
|
#apiKey;
|
|
26
39
|
#webhookSecret;
|
|
@@ -33,7 +46,7 @@ export class Skalp {
|
|
|
33
46
|
this.#apiKey = o.apiKey ?? env('SKALP_API_KEY');
|
|
34
47
|
this.#webhookSecret = o.webhookSecret ?? env('SKALP_WEBHOOK_SECRET');
|
|
35
48
|
this.#baseUrl = (o.baseUrl ?? env('SKALP_API_URL') ?? 'https://api.skalp.gg').replace(/\/+$/, '');
|
|
36
|
-
this.#timeout = o.timeout
|
|
49
|
+
this.#timeout = o.timeout && o.timeout > 0 ? o.timeout : DEFAULT_TIMEOUT_MS;
|
|
37
50
|
this.#maxRetries = o.maxRetries ?? 2;
|
|
38
51
|
this.#fetch = o.fetch ?? ((...args) => fetch(...args));
|
|
39
52
|
if (this.#apiKey && 'window' in globalThis && 'document' in globalThis && !o.dangerouslyAllowBrowser) {
|
|
@@ -41,12 +54,9 @@ export class Skalp {
|
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
checkout = {
|
|
44
|
-
create: (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
body: { items: [{ productId, quantity: 1 }], ...rest },
|
|
48
|
-
});
|
|
49
|
-
},
|
|
57
|
+
create: ({ productId, customerEmail, metadata, couponCode, returnUrl }) => this.#request('POST', '/v1/checkout/sessions', {
|
|
58
|
+
body: { items: [{ productId, quantity: 1 }], customerEmail, metadata, couponCode, returnUrl },
|
|
59
|
+
}),
|
|
50
60
|
get: (id) => this.#request('GET', `/v1/checkout/sessions/${segment(id)}`),
|
|
51
61
|
};
|
|
52
62
|
orders = {
|
|
@@ -56,47 +66,65 @@ export class Skalp {
|
|
|
56
66
|
list: () => this.#request('GET', '/v1/products'),
|
|
57
67
|
};
|
|
58
68
|
subscriptions = {
|
|
59
|
-
createCheckout: (
|
|
69
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }) => this.#request('POST', '/v1/subscriptions/checkout', {
|
|
70
|
+
body: { productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl },
|
|
71
|
+
}),
|
|
60
72
|
getCheckout: (id) => this.#request('GET', `/v1/subscriptions/checkout/${segment(id)}`),
|
|
61
|
-
list: (
|
|
73
|
+
list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
|
|
62
74
|
query: {
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
externalRef,
|
|
76
|
+
status,
|
|
77
|
+
updatedSince: updatedSince instanceof Date ? updatedSince.toISOString() : updatedSince,
|
|
78
|
+
limit,
|
|
79
|
+
offset,
|
|
65
80
|
},
|
|
66
81
|
}),
|
|
67
82
|
get: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}`),
|
|
83
|
+
changePlan: (id, { productId, productSlug }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/plan`, {
|
|
84
|
+
body: { productId, productSlug },
|
|
85
|
+
}),
|
|
68
86
|
cancel: (id) => this.#request('DELETE', `/v1/subscriptions/${segment(id)}`),
|
|
69
87
|
listInvoices: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}/invoices`).then((r) => r.data),
|
|
70
88
|
};
|
|
71
89
|
licenses = {
|
|
72
|
-
verify: (
|
|
73
|
-
|
|
90
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }) => this.#request('POST', '/licenses/verify', {
|
|
91
|
+
body: { licenseKey, serverIp, serverKey, serverPort },
|
|
92
|
+
auth: false,
|
|
93
|
+
}),
|
|
94
|
+
heartbeat: ({ licenseKey, serverIp }) => this.#request('POST', '/licenses/heartbeat', {
|
|
95
|
+
body: { licenseKey, serverIp },
|
|
96
|
+
auth: false,
|
|
97
|
+
}),
|
|
74
98
|
};
|
|
75
99
|
webhooks = {
|
|
76
|
-
verify: async (payload, signature, secret) =>
|
|
77
|
-
const key = secret ?? this.#webhookSecret;
|
|
78
|
-
if (!key) {
|
|
79
|
-
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
80
|
-
}
|
|
81
|
-
const encoder = new TextEncoder();
|
|
82
|
-
const body = toBytes(payload, encoder);
|
|
83
|
-
const parsed = parseSignature(readSignature(signature));
|
|
84
|
-
if (!parsed || Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
85
|
-
return false;
|
|
86
|
-
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
87
|
-
const signed = new Uint8Array(prefix.length + body.length);
|
|
88
|
-
signed.set(prefix);
|
|
89
|
-
signed.set(body, prefix.length);
|
|
90
|
-
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
91
|
-
return crypto.subtle.verify('HMAC', hmac, parsed.signature, signed);
|
|
92
|
-
},
|
|
100
|
+
verify: async (payload, signature, secret) => (await this.#checkSignature(payload, signature, secret)) === undefined,
|
|
93
101
|
constructEvent: async (payload, signature, secret) => {
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
const failure = await this.#checkSignature(payload, signature, secret);
|
|
103
|
+
if (failure)
|
|
104
|
+
throw new SkalpSignatureError(failure);
|
|
96
105
|
const text = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
|
|
97
106
|
return JSON.parse(text);
|
|
98
107
|
},
|
|
99
108
|
};
|
|
109
|
+
async #checkSignature(payload, signature, secret) {
|
|
110
|
+
const key = secret ?? this.#webhookSecret;
|
|
111
|
+
if (!key) {
|
|
112
|
+
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
113
|
+
}
|
|
114
|
+
const encoder = new TextEncoder();
|
|
115
|
+
const body = toBytes(payload, encoder);
|
|
116
|
+
const parsed = parseSignature(readSignature(signature));
|
|
117
|
+
if (!parsed)
|
|
118
|
+
return 'invalid';
|
|
119
|
+
if (Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
120
|
+
return 'stale';
|
|
121
|
+
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
122
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
123
|
+
signed.set(prefix);
|
|
124
|
+
signed.set(body, prefix.length);
|
|
125
|
+
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
126
|
+
return (await crypto.subtle.verify('HMAC', hmac, parsed.signature, signed)) ? undefined : 'invalid';
|
|
127
|
+
}
|
|
100
128
|
async #request(method, path, { body, query, auth = true } = {}) {
|
|
101
129
|
if (auth && !this.#apiKey) {
|
|
102
130
|
throw new Error('Skalp: missing API key. Pass it to new Skalp(apiKey) or set SKALP_API_KEY.');
|
|
@@ -106,18 +134,24 @@ export class Skalp {
|
|
|
106
134
|
if (value !== undefined && value !== null)
|
|
107
135
|
url.searchParams.set(name, String(value));
|
|
108
136
|
}
|
|
109
|
-
const headers = { accept: 'application/json' };
|
|
137
|
+
const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
|
|
110
138
|
if (auth)
|
|
111
139
|
headers['x-api-key'] = this.#apiKey;
|
|
112
140
|
if (body !== undefined)
|
|
113
141
|
headers['content-type'] = 'application/json';
|
|
114
142
|
for (let attempt = 0;; attempt++) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
let res;
|
|
144
|
+
try {
|
|
145
|
+
res = await this.#fetch(url, {
|
|
146
|
+
method,
|
|
147
|
+
headers,
|
|
148
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
149
|
+
signal: AbortSignal.timeout(this.#timeout),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (cause) {
|
|
153
|
+
throw new SkalpConnectionError(method, path, cause);
|
|
154
|
+
}
|
|
121
155
|
if (res.status === 429 && attempt < this.#maxRetries) {
|
|
122
156
|
const wait = retryAfterSeconds(res, attempt);
|
|
123
157
|
if (wait <= MAX_RETRY_WAIT_SECONDS) {
|
|
@@ -182,7 +216,7 @@ function retryAfterSeconds(res, attempt) {
|
|
|
182
216
|
const reset = Number(res.headers.get('x-ratelimit-reset'));
|
|
183
217
|
if (reset > 0)
|
|
184
218
|
return Math.max(reset - Date.now() / 1000, 0.5);
|
|
185
|
-
return 2 ** attempt;
|
|
219
|
+
return 2 ** Math.min(attempt, 5);
|
|
186
220
|
}
|
|
187
221
|
function toError(status, body) {
|
|
188
222
|
const b = (body && typeof body === 'object' ? body : {});
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -101,6 +101,10 @@ export interface Subscription {
|
|
|
101
101
|
createdAt: string;
|
|
102
102
|
updatedAt: string;
|
|
103
103
|
}
|
|
104
|
+
export interface ChangeSubscriptionPlanParams {
|
|
105
|
+
productId?: string;
|
|
106
|
+
productSlug?: string;
|
|
107
|
+
}
|
|
104
108
|
export interface ListSubscriptionsParams {
|
|
105
109
|
externalRef?: string;
|
|
106
110
|
status?: SubscriptionStatus;
|
|
@@ -151,7 +155,7 @@ export interface HeartbeatParams {
|
|
|
151
155
|
licenseKey: string;
|
|
152
156
|
serverIp: string;
|
|
153
157
|
}
|
|
154
|
-
interface
|
|
158
|
+
interface Envelope<TType extends string, TData> {
|
|
155
159
|
type: TType;
|
|
156
160
|
timestamp: string;
|
|
157
161
|
data: TData;
|
|
@@ -194,6 +198,10 @@ export interface OrderRefundedData {
|
|
|
194
198
|
refundId: string;
|
|
195
199
|
amount: number;
|
|
196
200
|
reason: string | null;
|
|
201
|
+
identifier: string | null;
|
|
202
|
+
steamHex: string | null;
|
|
203
|
+
coins?: number;
|
|
204
|
+
giftRecipient: boolean;
|
|
197
205
|
}
|
|
198
206
|
export interface OrderDisputedData {
|
|
199
207
|
orderId: string;
|
|
@@ -236,17 +244,17 @@ export interface WebhookTestData {
|
|
|
236
244
|
webhookId: string;
|
|
237
245
|
sample: Partial<OrderPaidData>;
|
|
238
246
|
}
|
|
239
|
-
export type SkalpEvent =
|
|
247
|
+
export type SkalpEvent = Envelope<'order.paid' | 'purchase.completed', OrderPaidData> | Envelope<'order.refunded', OrderRefundedData> | Envelope<'order.disputed' | 'account.suspend', OrderDisputedData> | Envelope<'account.restore', AccountRestoreData> | Envelope<'subscription.created' | 'subscription.updated', SubscriptionEventData> | Envelope<'subscription.renewed', SubscriptionEventData & {
|
|
240
248
|
invoiceId: string;
|
|
241
249
|
amountPaid: number;
|
|
242
250
|
periodExtendedTo: string;
|
|
243
|
-
}> |
|
|
251
|
+
}> | Envelope<'subscription.past_due', SubscriptionEventData & {
|
|
244
252
|
invoiceId: string;
|
|
245
253
|
attemptCount: number;
|
|
246
254
|
nextPaymentAttempt: string | null;
|
|
247
|
-
}> |
|
|
255
|
+
}> | Envelope<'subscription.canceled', SubscriptionEventData & {
|
|
248
256
|
endedAt: string | null;
|
|
249
257
|
cancelReason: 'requested' | 'payment_failure' | 'stripe';
|
|
250
|
-
}> |
|
|
258
|
+
}> | Envelope<'webhook.test', WebhookTestData>;
|
|
251
259
|
export type SkalpEventType = SkalpEvent['type'];
|
|
252
260
|
export {};
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skalpgg/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Official Skalp SDK for Node.js and edge runtimes: checkout, subscriptions, webhooks and license verification.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"publishConfig": { "access": "public" },
|
|
7
6
|
"homepage": "https://skalp.gg/docs/sdk",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://skalp.gg/contact"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
8
13
|
"keywords": [
|
|
9
14
|
"skalp",
|
|
10
15
|
"payments",
|