@skalpgg/sdk 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { AccessRule, AddAccessRuleParams, AddSerialsParams, AddSerialsResult, AdjustCustomerBalanceParams, AppealFeedbackParams, ChangeSubscriptionPlanParams, CheckoutSession, CheckoutSessionStatus, CoinCheckout, CoinCheckoutStatus, Coupon, CreateCheckoutParams, CreateCoinCheckoutParams, CreateCouponParams, CreateCustomerParams, CreateProductGroupParams, CreateProductParams, CreateStoreCategoryParams, CreateSubscriptionCheckoutParams, CreateUsageChargeParams, Customer, CustomerDetails, CustomerList, Deleted, Feedback, FeedbackList, HeartbeatParams, Invoice, LicenseKeyInfo, LicenseValidation, LicenseVerification, ListAccessRulesParams, ListCustomersParams, ListFeedbackParams, ListOrdersParams, ListProductsParams, ListQueriesParams, ListSerialsParams, ListSubscriptionsParams, Order, OrderKeysReplaced, OrderList, Product, ProductGroup, ProductStock, ReplaceOrderKeysParams, ReplyParams, SerialList, SkalpEvent, StoreCategory, StoreCategoryWithCount, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, SupportQueryDetails, SupportQueryList, ResetLicenseDevicesParams, UpdateCouponParams, UpdateCustomerParams, UpdateProductGroupParams, UpdateProductParams, UpdateStoreCategoryParams, UsageCharge, ValidateLicenseParams, VerifyLicenseParams } from './types.js';
2
2
  export type * from './types.js';
3
+ export declare const VERSION = "1.3.0";
3
4
  export interface SkalpOptions {
4
5
  apiKey?: string;
5
6
  webhookSecret?: string;
@@ -19,35 +20,113 @@ 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, quantity, customerEmail, metadata, couponCode, returnUrl, customFields }: CreateCheckoutParams) => Promise<CheckoutSession>;
30
36
  get: (id: string) => Promise<CheckoutSessionStatus>;
31
37
  };
32
38
  readonly orders: {
39
+ list: ({ status, productId, email, createdAfter, createdBefore, updatedSince, limit, offset }?: ListOrdersParams) => Promise<OrderList>;
33
40
  get: (id: string) => Promise<Order>;
41
+ cancel: (id: string) => Promise<Order>;
42
+ replace: (id: string, { serialIds, values, reason }?: ReplaceOrderKeysParams) => Promise<OrderKeysReplaced>;
34
43
  };
35
44
  readonly products: {
36
- list: () => Promise<Product[]>;
45
+ list: ({ status }?: ListProductsParams) => Promise<Product[]>;
46
+ get: (idOrSlug: string) => Promise<Product>;
47
+ create: (params: CreateProductParams) => Promise<Product>;
48
+ update: (idOrSlug: string, params: UpdateProductParams) => Promise<Product>;
49
+ archive: (idOrSlug: string) => Promise<Product>;
50
+ submit: (idOrSlug: string) => Promise<Product>;
51
+ stock: (idOrSlug: string) => Promise<ProductStock>;
52
+ listSerials: (idOrSlug: string, { status, limit, offset }?: ListSerialsParams) => Promise<SerialList>;
53
+ addSerials: (idOrSlug: string, { serials, text, delimiter }: AddSerialsParams) => Promise<AddSerialsResult>;
54
+ deleteSerial: (idOrSlug: string, serialId: string) => Promise<Deleted>;
55
+ };
56
+ readonly categories: {
57
+ list: () => Promise<StoreCategoryWithCount[]>;
58
+ create: ({ name }: CreateStoreCategoryParams) => Promise<StoreCategory>;
59
+ update: (id: string, { name, sortOrder }: UpdateStoreCategoryParams) => Promise<StoreCategory>;
60
+ delete: (id: string) => Promise<Deleted>;
61
+ };
62
+ readonly groups: {
63
+ list: () => Promise<ProductGroup[]>;
64
+ get: (id: string) => Promise<ProductGroup>;
65
+ create: (params: CreateProductGroupParams) => Promise<ProductGroup>;
66
+ update: (id: string, params: UpdateProductGroupParams) => Promise<ProductGroup>;
67
+ delete: (id: string) => Promise<Deleted>;
68
+ };
69
+ readonly coupons: {
70
+ list: () => Promise<Coupon[]>;
71
+ get: (id: string) => Promise<Coupon>;
72
+ create: (params: CreateCouponParams) => Promise<Coupon>;
73
+ update: (id: string, params: UpdateCouponParams) => Promise<Coupon>;
74
+ delete: (id: string) => Promise<Deleted>;
75
+ };
76
+ readonly customers: {
77
+ list: ({ search, limit, offset }?: ListCustomersParams) => Promise<CustomerList>;
78
+ get: (id: string) => Promise<CustomerDetails>;
79
+ create: ({ email, name, note }: CreateCustomerParams) => Promise<Customer>;
80
+ update: (id: string, { name, note }: UpdateCustomerParams) => Promise<Customer>;
81
+ delete: (id: string) => Promise<Deleted>;
82
+ adjustBalance: (id: string, { amount, reason }: AdjustCustomerBalanceParams) => Promise<Customer>;
83
+ };
84
+ readonly feedback: {
85
+ list: ({ productId, rating, limit, offset }?: ListFeedbackParams) => Promise<FeedbackList>;
86
+ get: (id: string) => Promise<Feedback>;
87
+ reply: (id: string, { message }: ReplyParams) => Promise<Feedback>;
88
+ appeal: (id: string, { reason }: AppealFeedbackParams) => Promise<Feedback>;
89
+ };
90
+ readonly queries: {
91
+ list: ({ status, limit, offset }?: ListQueriesParams) => Promise<SupportQueryList>;
92
+ get: (id: string) => Promise<SupportQueryDetails>;
93
+ reply: (id: string, { message }: ReplyParams) => Promise<SupportQueryDetails>;
94
+ close: (id: string) => Promise<SupportQueryDetails>;
95
+ reopen: (id: string) => Promise<SupportQueryDetails>;
96
+ };
97
+ readonly blacklist: {
98
+ list: ({ type }?: ListAccessRulesParams) => Promise<AccessRule[]>;
99
+ add: ({ type, value, note }: AddAccessRuleParams) => Promise<AccessRule>;
100
+ remove: (id: string) => Promise<Deleted>;
101
+ };
102
+ readonly whitelist: {
103
+ list: ({ type }?: ListAccessRulesParams) => Promise<AccessRule[]>;
104
+ add: ({ type, value, note }: AddAccessRuleParams) => Promise<AccessRule>;
105
+ remove: (id: string) => Promise<Deleted>;
106
+ };
107
+ readonly coins: {
108
+ createCheckout: ({ steamHex, coins, idempotencyKey, returnUrl }: CreateCoinCheckoutParams) => Promise<CoinCheckout>;
109
+ getCheckoutStatus: (reference: string) => Promise<CoinCheckoutStatus>;
37
110
  };
38
111
  readonly subscriptions: {
39
- createCheckout: (params: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
112
+ createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
40
113
  getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
41
- list: (params?: ListSubscriptionsParams) => Promise<SubscriptionList>;
114
+ list: ({ externalRef, status, updatedSince, limit, offset }?: ListSubscriptionsParams) => Promise<SubscriptionList>;
42
115
  get: (id: string) => Promise<Subscription>;
116
+ changePlan: (id: string, { productId, productSlug }: ChangeSubscriptionPlanParams) => Promise<Subscription>;
43
117
  cancel: (id: string) => Promise<Subscription>;
118
+ createUsageCharge: (id: string, { amount, currency, description, idempotencyKey, periodStart, periodEnd }: CreateUsageChargeParams) => Promise<UsageCharge>;
44
119
  listInvoices: (id: string) => Promise<Invoice[]>;
45
120
  };
46
121
  readonly licenses: {
47
- verify: (params: VerifyLicenseParams) => Promise<LicenseVerification>;
48
- heartbeat: (params: HeartbeatParams) => Promise<{
122
+ verify: ({ licenseKey, serverIp, serverKey, serverPort }: VerifyLicenseParams) => Promise<LicenseVerification>;
123
+ heartbeat: ({ licenseKey, serverIp }: HeartbeatParams) => Promise<{
49
124
  valid: boolean;
50
125
  }>;
126
+ validate: ({ key, productId, hardwareId }: ValidateLicenseParams) => Promise<LicenseValidation>;
127
+ resetDevices: ({ key, hardwareId }: ResetLicenseDevicesParams) => Promise<{
128
+ license: LicenseKeyInfo;
129
+ }>;
51
130
  };
52
131
  readonly webhooks: {
53
132
  verify: (payload: WebhookPayload, signature: SignatureSource, secret?: string) => Promise<boolean>;
package/dist/esm/index.js CHANGED
@@ -1,5 +1,9 @@
1
+ export const VERSION = '1.3.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,67 @@ 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;
37
+ const isoDate = (value) => (value instanceof Date ? value.toISOString() : value);
38
+ const optionalDate = (value) => value instanceof Date ? value.toISOString() : value;
39
+ const PRODUCT_FIELDS = [
40
+ 'name',
41
+ 'slug',
42
+ 'description',
43
+ 'shortDescription',
44
+ 'price',
45
+ 'comparePrice',
46
+ 'category',
47
+ 'categoryId',
48
+ 'storeCategoryId',
49
+ 'tags',
50
+ 'images',
51
+ 'videoUrl',
52
+ 'fulfillmentType',
53
+ 'billingInterval',
54
+ 'trialPeriodDays',
55
+ 'dynamicUrl',
56
+ 'licenseDeviceLimit',
57
+ 'licenseDurationDays',
58
+ ];
59
+ const COUPON_FIELDS = [
60
+ 'discountType',
61
+ 'discountPercent',
62
+ 'discountAmount',
63
+ 'productIds',
64
+ 'disabledWithVolumeDiscounts',
65
+ 'description',
66
+ 'maxUses',
67
+ 'minPurchaseAmount',
68
+ 'oneTimePerUser',
69
+ ];
70
+ const GROUP_FIELDS = ['title', 'productIds', 'description', 'image', 'sortPriority', 'unlisted'];
71
+ function pick(source, keys) {
72
+ const picked = {};
73
+ for (const key of keys) {
74
+ const value = source[key];
75
+ if (value !== undefined)
76
+ picked[key] = value;
77
+ }
78
+ return picked;
79
+ }
24
80
  export class Skalp {
25
81
  #apiKey;
26
82
  #webhookSecret;
@@ -33,7 +89,7 @@ export class Skalp {
33
89
  this.#apiKey = o.apiKey ?? env('SKALP_API_KEY');
34
90
  this.#webhookSecret = o.webhookSecret ?? env('SKALP_WEBHOOK_SECRET');
35
91
  this.#baseUrl = (o.baseUrl ?? env('SKALP_API_URL') ?? 'https://api.skalp.gg').replace(/\/+$/, '');
36
- this.#timeout = o.timeout ?? 30_000;
92
+ this.#timeout = o.timeout && o.timeout > 0 ? o.timeout : DEFAULT_TIMEOUT_MS;
37
93
  this.#maxRetries = o.maxRetries ?? 2;
38
94
  this.#fetch = o.fetch ?? ((...args) => fetch(...args));
39
95
  if (this.#apiKey && 'window' in globalThis && 'document' in globalThis && !o.dangerouslyAllowBrowser) {
@@ -41,62 +97,198 @@ export class Skalp {
41
97
  }
42
98
  }
43
99
  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
- },
100
+ create: ({ productId, quantity, customerEmail, metadata, couponCode, returnUrl, customFields }) => this.#request('POST', '/v1/checkout/sessions', {
101
+ body: { items: [{ productId, quantity }], customerEmail, metadata, couponCode, returnUrl, customFields },
102
+ }),
50
103
  get: (id) => this.#request('GET', `/v1/checkout/sessions/${segment(id)}`),
51
104
  };
52
105
  orders = {
106
+ list: ({ status, productId, email, createdAfter, createdBefore, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/orders', {
107
+ query: {
108
+ status,
109
+ productId,
110
+ email,
111
+ createdAfter: optionalDate(createdAfter),
112
+ createdBefore: optionalDate(createdBefore),
113
+ updatedSince: optionalDate(updatedSince),
114
+ limit,
115
+ offset,
116
+ },
117
+ }),
53
118
  get: (id) => this.#request('GET', `/v1/orders/${segment(id)}`),
119
+ cancel: (id) => this.#request('POST', `/v1/orders/${segment(id)}/cancel`),
120
+ replace: (id, { serialIds, values, reason } = {}) => this.#request('POST', `/v1/orders/${segment(id)}/replacement`, {
121
+ body: { serialIds, values, reason },
122
+ }),
54
123
  };
55
124
  products = {
56
- list: () => this.#request('GET', '/v1/products'),
125
+ list: ({ status } = {}) => this.#request('GET', '/v1/products', { query: { status } }),
126
+ get: (idOrSlug) => this.#request('GET', `/v1/products/${segment(idOrSlug)}`),
127
+ create: (params) => this.#request('POST', '/v1/products', {
128
+ body: pick(params, [...PRODUCT_FIELDS, 'productType', 'submit']),
129
+ }),
130
+ update: (idOrSlug, params) => this.#request('PATCH', `/v1/products/${segment(idOrSlug)}`, {
131
+ body: pick(params, [...PRODUCT_FIELDS, 'status']),
132
+ }),
133
+ archive: (idOrSlug) => this.#request('DELETE', `/v1/products/${segment(idOrSlug)}`),
134
+ submit: (idOrSlug) => this.#request('POST', `/v1/products/${segment(idOrSlug)}/submit`),
135
+ stock: (idOrSlug) => this.#request('GET', `/v1/products/${segment(idOrSlug)}/stock`),
136
+ listSerials: (idOrSlug, { status, limit, offset } = {}) => this.#request('GET', `/v1/products/${segment(idOrSlug)}/serials`, {
137
+ query: { status, limit, offset },
138
+ }),
139
+ addSerials: (idOrSlug, { serials, text, delimiter }) => this.#request('POST', `/v1/products/${segment(idOrSlug)}/serials`, {
140
+ body: { serials, text, delimiter },
141
+ }),
142
+ deleteSerial: (idOrSlug, serialId) => this.#request('DELETE', `/v1/products/${segment(idOrSlug)}/serials/${segment(serialId)}`),
143
+ };
144
+ categories = {
145
+ list: () => this.#request('GET', '/v1/categories'),
146
+ create: ({ name }) => this.#request('POST', '/v1/categories', { body: { name } }),
147
+ update: (id, { name, sortOrder }) => this.#request('PATCH', `/v1/categories/${segment(id)}`, { body: { name, sortOrder } }),
148
+ delete: (id) => this.#request('DELETE', `/v1/categories/${segment(id)}`),
149
+ };
150
+ groups = {
151
+ list: () => this.#request('GET', '/v1/groups'),
152
+ get: (id) => this.#request('GET', `/v1/groups/${segment(id)}`),
153
+ create: (params) => this.#request('POST', '/v1/groups', { body: pick(params, GROUP_FIELDS) }),
154
+ update: (id, params) => this.#request('PATCH', `/v1/groups/${segment(id)}`, { body: pick(params, GROUP_FIELDS) }),
155
+ delete: (id) => this.#request('DELETE', `/v1/groups/${segment(id)}`),
156
+ };
157
+ coupons = {
158
+ list: () => this.#request('GET', '/v1/coupons'),
159
+ get: (id) => this.#request('GET', `/v1/coupons/${segment(id)}`),
160
+ create: (params) => this.#request('POST', '/v1/coupons', {
161
+ body: {
162
+ ...pick(params, [...COUPON_FIELDS, 'code']),
163
+ validFrom: optionalDate(params.validFrom),
164
+ validTo: optionalDate(params.validTo),
165
+ },
166
+ }),
167
+ update: (id, params) => this.#request('PATCH', `/v1/coupons/${segment(id)}`, {
168
+ body: {
169
+ ...pick(params, [...COUPON_FIELDS, 'isActive']),
170
+ validFrom: optionalDate(params.validFrom),
171
+ validTo: optionalDate(params.validTo),
172
+ },
173
+ }),
174
+ delete: (id) => this.#request('DELETE', `/v1/coupons/${segment(id)}`),
175
+ };
176
+ customers = {
177
+ list: ({ search, limit, offset } = {}) => this.#request('GET', '/v1/customers', { query: { search, limit, offset } }),
178
+ get: (id) => this.#request('GET', `/v1/customers/${segment(id)}`),
179
+ create: ({ email, name, note }) => this.#request('POST', '/v1/customers', { body: { email, name, note } }),
180
+ update: (id, { name, note }) => this.#request('PATCH', `/v1/customers/${segment(id)}`, { body: { name, note } }),
181
+ delete: (id) => this.#request('DELETE', `/v1/customers/${segment(id)}`),
182
+ adjustBalance: (id, { amount, reason }) => this.#request('POST', `/v1/customers/${segment(id)}/balance`, { body: { amount, reason } }),
183
+ };
184
+ feedback = {
185
+ list: ({ productId, rating, limit, offset } = {}) => this.#request('GET', '/v1/feedback', { query: { productId, rating, limit, offset } }),
186
+ get: (id) => this.#request('GET', `/v1/feedback/${segment(id)}`),
187
+ reply: (id, { message }) => this.#request('POST', `/v1/feedback/${segment(id)}/reply`, { body: { message } }),
188
+ appeal: (id, { reason }) => this.#request('POST', `/v1/feedback/${segment(id)}/appeal`, { body: { reason } }),
189
+ };
190
+ queries = {
191
+ list: ({ status, limit, offset } = {}) => this.#request('GET', '/v1/queries', { query: { status, limit, offset } }),
192
+ get: (id) => this.#request('GET', `/v1/queries/${segment(id)}`),
193
+ reply: (id, { message }) => this.#request('POST', `/v1/queries/${segment(id)}/reply`, { body: { message } }),
194
+ close: (id) => this.#request('POST', `/v1/queries/${segment(id)}/close`),
195
+ reopen: (id) => this.#request('POST', `/v1/queries/${segment(id)}/reopen`),
196
+ };
197
+ blacklist = this.#accessRules('blacklist');
198
+ whitelist = this.#accessRules('whitelist');
199
+ #accessRules(list) {
200
+ return {
201
+ list: ({ type } = {}) => this.#request('GET', `/v1/${list}`, { query: { type } }),
202
+ add: ({ type, value, note }) => this.#request('POST', `/v1/${list}`, { body: { type, value, note } }),
203
+ remove: (id) => this.#request('DELETE', `/v1/${list}/${segment(id)}`),
204
+ };
205
+ }
206
+ coins = {
207
+ createCheckout: ({ steamHex, coins, idempotencyKey, returnUrl }) => this.#request('POST', '/coins/checkout', {
208
+ body: { steamHex, coins, idempotencyKey, returnUrl },
209
+ }),
210
+ getCheckoutStatus: (reference) => this.#request('GET', `/coins/checkout/${segment(reference)}/status`),
57
211
  };
58
212
  subscriptions = {
59
- createCheckout: (params) => this.#request('POST', '/v1/subscriptions/checkout', { body: params }),
213
+ createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }) => this.#request('POST', '/v1/subscriptions/checkout', {
214
+ body: { productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl },
215
+ }),
60
216
  getCheckout: (id) => this.#request('GET', `/v1/subscriptions/checkout/${segment(id)}`),
61
- list: (params = {}) => this.#request('GET', '/v1/subscriptions', {
217
+ list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
62
218
  query: {
63
- ...params,
64
- updatedSince: params.updatedSince instanceof Date ? params.updatedSince.toISOString() : params.updatedSince,
219
+ externalRef,
220
+ status,
221
+ updatedSince: updatedSince instanceof Date ? updatedSince.toISOString() : updatedSince,
222
+ limit,
223
+ offset,
65
224
  },
66
225
  }),
67
226
  get: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}`),
227
+ changePlan: (id, { productId, productSlug }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/plan`, {
228
+ body: { productId, productSlug },
229
+ }),
68
230
  cancel: (id) => this.#request('DELETE', `/v1/subscriptions/${segment(id)}`),
231
+ createUsageCharge: (id, { amount, currency, description, idempotencyKey, periodStart, periodEnd }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/usage-charges`, {
232
+ body: {
233
+ amount,
234
+ currency,
235
+ description,
236
+ idempotencyKey,
237
+ periodStart: isoDate(periodStart),
238
+ periodEnd: isoDate(periodEnd),
239
+ },
240
+ }),
69
241
  listInvoices: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}/invoices`).then((r) => r.data),
70
242
  };
71
243
  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 }),
244
+ verify: ({ licenseKey, serverIp, serverKey, serverPort }) => this.#request('POST', '/licenses/verify', {
245
+ body: { licenseKey, serverIp, serverKey, serverPort },
246
+ auth: false,
247
+ }),
248
+ heartbeat: ({ licenseKey, serverIp }) => this.#request('POST', '/licenses/heartbeat', {
249
+ body: { licenseKey, serverIp },
250
+ auth: false,
251
+ }),
252
+ // Keys sold with the "License key" delivery. Needs the licenses:validate
253
+ // scope; a key with only that scope may ship inside your app.
254
+ validate: ({ key, productId, hardwareId }) => this.#request('POST', '/v1/licenses/validate', {
255
+ body: { key, productId, hardwareId },
256
+ }),
257
+ // Frees the machines a key is bound to. Needs licenses:write; call it
258
+ // from your server only.
259
+ resetDevices: ({ key, hardwareId }) => this.#request('POST', '/v1/licenses/devices/reset', {
260
+ body: { key, hardwareId },
261
+ }),
74
262
  };
75
263
  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
- },
264
+ verify: async (payload, signature, secret) => (await this.#checkSignature(payload, signature, secret)) === undefined,
93
265
  constructEvent: async (payload, signature, secret) => {
94
- if (!(await this.webhooks.verify(payload, signature, secret)))
95
- throw new SkalpSignatureError();
266
+ const failure = await this.#checkSignature(payload, signature, secret);
267
+ if (failure)
268
+ throw new SkalpSignatureError(failure);
96
269
  const text = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
97
270
  return JSON.parse(text);
98
271
  },
99
272
  };
273
+ async #checkSignature(payload, signature, secret) {
274
+ const key = secret ?? this.#webhookSecret;
275
+ if (!key) {
276
+ throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
277
+ }
278
+ const encoder = new TextEncoder();
279
+ const body = toBytes(payload, encoder);
280
+ const parsed = parseSignature(readSignature(signature));
281
+ if (!parsed)
282
+ return 'invalid';
283
+ if (Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
284
+ return 'stale';
285
+ const prefix = encoder.encode(`${parsed.timestamp}.`);
286
+ const signed = new Uint8Array(prefix.length + body.length);
287
+ signed.set(prefix);
288
+ signed.set(body, prefix.length);
289
+ const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
290
+ return (await crypto.subtle.verify('HMAC', hmac, parsed.signature, signed)) ? undefined : 'invalid';
291
+ }
100
292
  async #request(method, path, { body, query, auth = true } = {}) {
101
293
  if (auth && !this.#apiKey) {
102
294
  throw new Error('Skalp: missing API key. Pass it to new Skalp(apiKey) or set SKALP_API_KEY.');
@@ -106,18 +298,24 @@ export class Skalp {
106
298
  if (value !== undefined && value !== null)
107
299
  url.searchParams.set(name, String(value));
108
300
  }
109
- const headers = { accept: 'application/json' };
301
+ const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
110
302
  if (auth)
111
303
  headers['x-api-key'] = this.#apiKey;
112
304
  if (body !== undefined)
113
305
  headers['content-type'] = 'application/json';
114
306
  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
- });
307
+ let res;
308
+ try {
309
+ res = await this.#fetch(url, {
310
+ method,
311
+ headers,
312
+ body: body === undefined ? undefined : JSON.stringify(body),
313
+ signal: AbortSignal.timeout(this.#timeout),
314
+ });
315
+ }
316
+ catch (cause) {
317
+ throw new SkalpConnectionError(method, path, cause);
318
+ }
121
319
  if (res.status === 429 && attempt < this.#maxRetries) {
122
320
  const wait = retryAfterSeconds(res, attempt);
123
321
  if (wait <= MAX_RETRY_WAIT_SECONDS) {
@@ -182,7 +380,7 @@ function retryAfterSeconds(res, attempt) {
182
380
  const reset = Number(res.headers.get('x-ratelimit-reset'));
183
381
  if (reset > 0)
184
382
  return Math.max(reset - Date.now() / 1000, 0.5);
185
- return 2 ** attempt;
383
+ return 2 ** Math.min(attempt, 5);
186
384
  }
187
385
  function toError(status, body) {
188
386
  const b = (body && typeof body === 'object' ? body : {});