@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 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 errors throw `SkalpError` with `status`, `code` (for example `STORE_REQUIRED`) and the parsed `body`. A `429` is retried up to `maxRetries` times (default 2) when the limit resets within 10 seconds. An invalid webhook signature throws `SkalpSignatureError`.
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.
@@ -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
- constructor(message?: string);
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: (params: CreateCheckoutParams) => Promise<CheckoutSession>;
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: (params: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
45
+ createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
40
46
  getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
41
- list: (params?: ListSubscriptionsParams) => Promise<SubscriptionList>;
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: (params: VerifyLicenseParams) => Promise<LicenseVerification>;
48
- heartbeat: (params: HeartbeatParams) => Promise<{
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
- constructor(message = 'Invalid Skalp webhook signature') {
21
- super(message);
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 ?? 30_000;
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: (params) => {
50
- const { productId, ...rest } = params;
51
- return this.#request('POST', '/v1/checkout/sessions', {
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: (params) => this.#request('POST', '/v1/subscriptions/checkout', { body: params }),
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: (params = {}) => this.#request('GET', '/v1/subscriptions', {
79
+ list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
67
80
  query: {
68
- ...params,
69
- updatedSince: params.updatedSince instanceof Date ? params.updatedSince.toISOString() : params.updatedSince,
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: (params) => this.#request('POST', '/licenses/verify', { body: params, auth: false }),
78
- heartbeat: (params) => this.#request('POST', '/licenses/heartbeat', { body: params, auth: false }),
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
- if (!(await this.webhooks.verify(payload, signature, secret)))
100
- throw new SkalpSignatureError();
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
- const res = await this.#fetch(url, {
121
- method,
122
- headers,
123
- body: body === undefined ? undefined : JSON.stringify(body),
124
- signal: AbortSignal.timeout(this.#timeout),
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 : {});
@@ -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 Event<TType extends string, TData> {
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 = Event<'order.paid' | 'purchase.completed', OrderPaidData> | Event<'order.refunded', OrderRefundedData> | Event<'order.disputed' | 'account.suspend', OrderDisputedData> | Event<'account.restore', AccountRestoreData> | Event<'subscription.created' | 'subscription.updated', SubscriptionEventData> | Event<'subscription.renewed', SubscriptionEventData & {
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
- }> | Event<'subscription.past_due', SubscriptionEventData & {
251
+ }> | Envelope<'subscription.past_due', SubscriptionEventData & {
244
252
  invoiceId: string;
245
253
  attemptCount: number;
246
254
  nextPaymentAttempt: string | null;
247
- }> | Event<'subscription.canceled', SubscriptionEventData & {
255
+ }> | Envelope<'subscription.canceled', SubscriptionEventData & {
248
256
  endedAt: string | null;
249
257
  cancelReason: 'requested' | 'payment_failure' | 'stripe';
250
- }> | Event<'webhook.test', WebhookTestData>;
258
+ }> | Envelope<'webhook.test', WebhookTestData>;
251
259
  export type SkalpEventType = SkalpEvent['type'];
252
260
  export {};
@@ -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
- constructor(message?: string);
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: (params: CreateCheckoutParams) => Promise<CheckoutSession>;
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: (params: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
45
+ createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
40
46
  getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
41
- list: (params?: ListSubscriptionsParams) => Promise<SubscriptionList>;
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: (params: VerifyLicenseParams) => Promise<LicenseVerification>;
48
- heartbeat: (params: HeartbeatParams) => Promise<{
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
- constructor(message = 'Invalid Skalp webhook signature') {
17
- super(message);
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 ?? 30_000;
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: (params) => {
45
- const { productId, ...rest } = params;
46
- return this.#request('POST', '/v1/checkout/sessions', {
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: (params) => this.#request('POST', '/v1/subscriptions/checkout', { body: params }),
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: (params = {}) => this.#request('GET', '/v1/subscriptions', {
73
+ list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
62
74
  query: {
63
- ...params,
64
- updatedSince: params.updatedSince instanceof Date ? params.updatedSince.toISOString() : params.updatedSince,
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: (params) => this.#request('POST', '/licenses/verify', { body: params, auth: false }),
73
- heartbeat: (params) => this.#request('POST', '/licenses/heartbeat', { body: params, auth: false }),
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
- if (!(await this.webhooks.verify(payload, signature, secret)))
95
- throw new SkalpSignatureError();
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
- const res = await this.#fetch(url, {
116
- method,
117
- headers,
118
- body: body === undefined ? undefined : JSON.stringify(body),
119
- signal: AbortSignal.timeout(this.#timeout),
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 : {});
@@ -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 Event<TType extends string, TData> {
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 = Event<'order.paid' | 'purchase.completed', OrderPaidData> | Event<'order.refunded', OrderRefundedData> | Event<'order.disputed' | 'account.suspend', OrderDisputedData> | Event<'account.restore', AccountRestoreData> | Event<'subscription.created' | 'subscription.updated', SubscriptionEventData> | Event<'subscription.renewed', SubscriptionEventData & {
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
- }> | Event<'subscription.past_due', SubscriptionEventData & {
251
+ }> | Envelope<'subscription.past_due', SubscriptionEventData & {
244
252
  invoiceId: string;
245
253
  attemptCount: number;
246
254
  nextPaymentAttempt: string | null;
247
- }> | Event<'subscription.canceled', SubscriptionEventData & {
255
+ }> | Envelope<'subscription.canceled', SubscriptionEventData & {
248
256
  endedAt: string | null;
249
257
  cancelReason: 'requested' | 'payment_failure' | 'stripe';
250
- }> | Event<'webhook.test', WebhookTestData>;
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.0.0",
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",