@scell/sdk 1.0.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/LICENSE +21 -0
- package/README.md +464 -0
- package/dist/index.d.mts +2292 -0
- package/dist/index.d.ts +2292 -0
- package/dist/index.js +1755 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1748 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +71 -0
- package/src/client.ts +297 -0
- package/src/errors.ts +256 -0
- package/src/index.ts +195 -0
- package/src/resources/api-keys.ts +141 -0
- package/src/resources/auth.ts +286 -0
- package/src/resources/balance.ts +157 -0
- package/src/resources/companies.ts +224 -0
- package/src/resources/invoices.ts +246 -0
- package/src/resources/signatures.ts +245 -0
- package/src/resources/webhooks.ts +258 -0
- package/src/types/api-keys.ts +35 -0
- package/src/types/auth.ts +58 -0
- package/src/types/balance.ts +87 -0
- package/src/types/common.ts +114 -0
- package/src/types/companies.ts +86 -0
- package/src/types/index.ts +115 -0
- package/src/types/invoices.ts +191 -0
- package/src/types/signatures.ts +153 -0
- package/src/types/webhooks.ts +146 -0
- package/src/utils/retry.ts +167 -0
- package/src/utils/webhook-verify.ts +290 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry utility with exponential backoff and jitter
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ScellRateLimitError, ScellServerError } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Retry configuration options
|
|
11
|
+
*/
|
|
12
|
+
export interface RetryOptions {
|
|
13
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
14
|
+
maxRetries?: number | undefined;
|
|
15
|
+
/** Base delay in milliseconds (default: 1000) */
|
|
16
|
+
baseDelay?: number | undefined;
|
|
17
|
+
/** Maximum delay in milliseconds (default: 30000) */
|
|
18
|
+
maxDelay?: number | undefined;
|
|
19
|
+
/** Jitter factor (0-1, default: 0.1) */
|
|
20
|
+
jitterFactor?: number | undefined;
|
|
21
|
+
/** Custom function to determine if error is retryable */
|
|
22
|
+
isRetryable?: ((error: unknown) => boolean) | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default retry options
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_RETRY_OPTIONS: Required<Omit<RetryOptions, 'isRetryable'>> = {
|
|
29
|
+
maxRetries: 3,
|
|
30
|
+
baseDelay: 1000,
|
|
31
|
+
maxDelay: 30000,
|
|
32
|
+
jitterFactor: 0.1,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if an error is retryable (429 or 5xx)
|
|
37
|
+
*/
|
|
38
|
+
export function isRetryableError(error: unknown): boolean {
|
|
39
|
+
if (error instanceof ScellRateLimitError) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (error instanceof ScellServerError) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// Network errors are also retryable
|
|
46
|
+
if (error instanceof Error && error.name === 'ScellNetworkError') {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate delay with exponential backoff and jitter
|
|
54
|
+
*
|
|
55
|
+
* @param attempt - Current attempt number (0-indexed)
|
|
56
|
+
* @param baseDelay - Base delay in milliseconds
|
|
57
|
+
* @param maxDelay - Maximum delay in milliseconds
|
|
58
|
+
* @param jitterFactor - Jitter factor (0-1)
|
|
59
|
+
* @param retryAfter - Optional retry-after header value in seconds
|
|
60
|
+
* @returns Delay in milliseconds
|
|
61
|
+
*/
|
|
62
|
+
export function calculateDelay(
|
|
63
|
+
attempt: number,
|
|
64
|
+
baseDelay: number,
|
|
65
|
+
maxDelay: number,
|
|
66
|
+
jitterFactor: number,
|
|
67
|
+
retryAfter?: number
|
|
68
|
+
): number {
|
|
69
|
+
// If we have a retry-after header, use it
|
|
70
|
+
if (retryAfter !== undefined && retryAfter > 0) {
|
|
71
|
+
return retryAfter * 1000;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
75
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
76
|
+
|
|
77
|
+
// Cap at maxDelay
|
|
78
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelay);
|
|
79
|
+
|
|
80
|
+
// Add jitter: delay * (1 +/- jitterFactor * random)
|
|
81
|
+
const jitter = cappedDelay * jitterFactor * (Math.random() * 2 - 1);
|
|
82
|
+
|
|
83
|
+
return Math.floor(cappedDelay + jitter);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sleep for a specified duration
|
|
88
|
+
*/
|
|
89
|
+
function sleep(ms: number): Promise<void> {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Execute a function with retry logic
|
|
95
|
+
*
|
|
96
|
+
* @param fn - Async function to execute
|
|
97
|
+
* @param options - Retry options
|
|
98
|
+
* @returns Result of the function
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* const result = await withRetry(
|
|
103
|
+
* () => client.invoices.create(data),
|
|
104
|
+
* { maxRetries: 5 }
|
|
105
|
+
* );
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export async function withRetry<T>(
|
|
109
|
+
fn: () => Promise<T>,
|
|
110
|
+
options: RetryOptions = {}
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
const maxRetries = options.maxRetries ?? DEFAULT_RETRY_OPTIONS.maxRetries;
|
|
113
|
+
const baseDelay = options.baseDelay ?? DEFAULT_RETRY_OPTIONS.baseDelay;
|
|
114
|
+
const maxDelay = options.maxDelay ?? DEFAULT_RETRY_OPTIONS.maxDelay;
|
|
115
|
+
const jitterFactor = options.jitterFactor ?? DEFAULT_RETRY_OPTIONS.jitterFactor;
|
|
116
|
+
const isRetryable = options.isRetryable ?? isRetryableError;
|
|
117
|
+
|
|
118
|
+
let lastError: unknown;
|
|
119
|
+
|
|
120
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
lastError = error;
|
|
125
|
+
|
|
126
|
+
// Check if we should retry
|
|
127
|
+
if (attempt >= maxRetries || !isRetryable(error)) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Get retry-after header if available
|
|
132
|
+
const retryAfter: number | undefined =
|
|
133
|
+
error instanceof ScellRateLimitError ? error.retryAfter : undefined;
|
|
134
|
+
|
|
135
|
+
// Calculate delay
|
|
136
|
+
const delay = calculateDelay(
|
|
137
|
+
attempt,
|
|
138
|
+
baseDelay,
|
|
139
|
+
maxDelay,
|
|
140
|
+
jitterFactor,
|
|
141
|
+
retryAfter
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Wait before retrying
|
|
145
|
+
await sleep(delay);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// This should never be reached, but TypeScript needs it
|
|
150
|
+
throw lastError;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create a retry wrapper with pre-configured options
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```typescript
|
|
158
|
+
* const retry = createRetryWrapper({ maxRetries: 5 });
|
|
159
|
+
* const result = await retry(() => client.invoices.create(data));
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export function createRetryWrapper(
|
|
163
|
+
defaultOptions: RetryOptions = {}
|
|
164
|
+
): <T>(fn: () => Promise<T>, options?: RetryOptions) => Promise<T> {
|
|
165
|
+
return <T>(fn: () => Promise<T>, options: RetryOptions = {}) =>
|
|
166
|
+
withRetry(fn, { ...defaultOptions, ...options });
|
|
167
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook signature verification utility
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WebhookPayload } from '../types/webhooks.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signature verification options
|
|
11
|
+
*/
|
|
12
|
+
export interface VerifySignatureOptions {
|
|
13
|
+
/** Tolerance in seconds for timestamp validation (default: 300 = 5 minutes) */
|
|
14
|
+
tolerance?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse the X-Scell-Signature header
|
|
19
|
+
*
|
|
20
|
+
* @param header - The signature header value (format: "t=timestamp,v1=signature")
|
|
21
|
+
* @returns Parsed timestamp and signature
|
|
22
|
+
*/
|
|
23
|
+
function parseSignatureHeader(header: string): {
|
|
24
|
+
timestamp: number;
|
|
25
|
+
signature: string;
|
|
26
|
+
} | null {
|
|
27
|
+
const parts = header.split(',');
|
|
28
|
+
let timestamp: number | undefined;
|
|
29
|
+
let signature: string | undefined;
|
|
30
|
+
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
const splitIndex = part.indexOf('=');
|
|
33
|
+
if (splitIndex === -1) continue;
|
|
34
|
+
|
|
35
|
+
const key = part.substring(0, splitIndex);
|
|
36
|
+
const value = part.substring(splitIndex + 1);
|
|
37
|
+
|
|
38
|
+
if (key === 't') {
|
|
39
|
+
timestamp = parseInt(value, 10);
|
|
40
|
+
} else if (key === 'v1') {
|
|
41
|
+
signature = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (timestamp === undefined || signature === undefined) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { timestamp, signature };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute HMAC-SHA256 signature
|
|
54
|
+
*
|
|
55
|
+
* @param payload - The payload string
|
|
56
|
+
* @param timestamp - The timestamp
|
|
57
|
+
* @param secret - The webhook secret
|
|
58
|
+
* @returns Hex-encoded signature
|
|
59
|
+
*/
|
|
60
|
+
async function computeSignature(
|
|
61
|
+
payload: string,
|
|
62
|
+
timestamp: number,
|
|
63
|
+
secret: string
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
66
|
+
|
|
67
|
+
// Use Web Crypto API (works in Node.js 18+ and browsers)
|
|
68
|
+
const encoder = new TextEncoder();
|
|
69
|
+
const keyData = encoder.encode(secret);
|
|
70
|
+
const messageData = encoder.encode(signedPayload);
|
|
71
|
+
|
|
72
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
73
|
+
'raw',
|
|
74
|
+
keyData,
|
|
75
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
76
|
+
false,
|
|
77
|
+
['sign']
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const signatureBuffer = await crypto.subtle.sign(
|
|
81
|
+
'HMAC',
|
|
82
|
+
cryptoKey,
|
|
83
|
+
messageData
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Convert to hex string
|
|
87
|
+
return Array.from(new Uint8Array(signatureBuffer))
|
|
88
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
89
|
+
.join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Constant-time string comparison to prevent timing attacks
|
|
94
|
+
*/
|
|
95
|
+
function secureCompare(a: string, b: string): boolean {
|
|
96
|
+
if (a.length !== b.length) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let result = 0;
|
|
101
|
+
for (let i = 0; i < a.length; i++) {
|
|
102
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result === 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Scell Webhook signature verification utilities
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* import { ScellWebhooks } from '@scell/sdk';
|
|
114
|
+
*
|
|
115
|
+
* // In your webhook endpoint
|
|
116
|
+
* app.post('/webhooks/scell', async (req, res) => {
|
|
117
|
+
* const signature = req.headers['x-scell-signature'];
|
|
118
|
+
* const payload = JSON.stringify(req.body);
|
|
119
|
+
*
|
|
120
|
+
* const isValid = await ScellWebhooks.verifySignature(
|
|
121
|
+
* payload,
|
|
122
|
+
* signature,
|
|
123
|
+
* process.env.WEBHOOK_SECRET
|
|
124
|
+
* );
|
|
125
|
+
*
|
|
126
|
+
* if (!isValid) {
|
|
127
|
+
* return res.status(401).send('Invalid signature');
|
|
128
|
+
* }
|
|
129
|
+
*
|
|
130
|
+
* // Process the webhook
|
|
131
|
+
* const event = ScellWebhooks.parsePayload(payload);
|
|
132
|
+
* console.log('Received event:', event.event);
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export const ScellWebhooks = {
|
|
137
|
+
/**
|
|
138
|
+
* Verify webhook signature
|
|
139
|
+
*
|
|
140
|
+
* @param payload - Raw request body as string
|
|
141
|
+
* @param signature - Value of X-Scell-Signature header
|
|
142
|
+
* @param secret - Your webhook secret (whsec_...)
|
|
143
|
+
* @param options - Verification options
|
|
144
|
+
* @returns True if signature is valid
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* const isValid = await ScellWebhooks.verifySignature(
|
|
149
|
+
* rawBody,
|
|
150
|
+
* req.headers['x-scell-signature'],
|
|
151
|
+
* 'whsec_abc123...'
|
|
152
|
+
* );
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
async verifySignature(
|
|
156
|
+
payload: string,
|
|
157
|
+
signature: string,
|
|
158
|
+
secret: string,
|
|
159
|
+
options: VerifySignatureOptions = {}
|
|
160
|
+
): Promise<boolean> {
|
|
161
|
+
const { tolerance = 300 } = options;
|
|
162
|
+
|
|
163
|
+
// Parse the signature header
|
|
164
|
+
const parsed = parseSignatureHeader(signature);
|
|
165
|
+
if (!parsed) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { timestamp, signature: providedSignature } = parsed;
|
|
170
|
+
|
|
171
|
+
// Check timestamp tolerance
|
|
172
|
+
const now = Math.floor(Date.now() / 1000);
|
|
173
|
+
if (Math.abs(now - timestamp) > tolerance) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Compute expected signature
|
|
178
|
+
const expectedSignature = await computeSignature(payload, timestamp, secret);
|
|
179
|
+
|
|
180
|
+
// Constant-time comparison
|
|
181
|
+
return secureCompare(expectedSignature, providedSignature);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Verify webhook signature synchronously using Node.js crypto
|
|
186
|
+
* (Only works in Node.js environment)
|
|
187
|
+
*
|
|
188
|
+
* @param payload - Raw request body as string
|
|
189
|
+
* @param signature - Value of X-Scell-Signature header
|
|
190
|
+
* @param secret - Your webhook secret (whsec_...)
|
|
191
|
+
* @param options - Verification options
|
|
192
|
+
* @returns True if signature is valid
|
|
193
|
+
*/
|
|
194
|
+
verifySignatureSync(
|
|
195
|
+
payload: string,
|
|
196
|
+
signature: string,
|
|
197
|
+
secret: string,
|
|
198
|
+
options: VerifySignatureOptions = {}
|
|
199
|
+
): boolean {
|
|
200
|
+
const { tolerance = 300 } = options;
|
|
201
|
+
|
|
202
|
+
// Parse the signature header
|
|
203
|
+
const parsed = parseSignatureHeader(signature);
|
|
204
|
+
if (!parsed) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { timestamp, signature: providedSignature } = parsed;
|
|
209
|
+
|
|
210
|
+
// Check timestamp tolerance
|
|
211
|
+
const now = Math.floor(Date.now() / 1000);
|
|
212
|
+
if (Math.abs(now - timestamp) > tolerance) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Use Node.js crypto for sync operation
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
218
|
+
const crypto = require('crypto') as typeof import('crypto');
|
|
219
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
220
|
+
const expectedSignature = crypto
|
|
221
|
+
.createHmac('sha256', secret)
|
|
222
|
+
.update(signedPayload)
|
|
223
|
+
.digest('hex');
|
|
224
|
+
|
|
225
|
+
// Use Node.js timingSafeEqual
|
|
226
|
+
try {
|
|
227
|
+
return crypto.timingSafeEqual(
|
|
228
|
+
Buffer.from(expectedSignature),
|
|
229
|
+
Buffer.from(providedSignature)
|
|
230
|
+
);
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse webhook payload
|
|
238
|
+
*
|
|
239
|
+
* @param payload - Raw request body as string
|
|
240
|
+
* @returns Parsed webhook payload
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```typescript
|
|
244
|
+
* const event = ScellWebhooks.parsePayload<InvoiceWebhookData>(rawBody);
|
|
245
|
+
* if (event.event === 'invoice.validated') {
|
|
246
|
+
* console.log('Invoice validated:', event.data.invoice_number);
|
|
247
|
+
* }
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
parsePayload<T = unknown>(payload: string): WebhookPayload<T> {
|
|
251
|
+
return JSON.parse(payload) as WebhookPayload<T>;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Construct a test webhook event for local testing
|
|
256
|
+
*
|
|
257
|
+
* @param event - Event type
|
|
258
|
+
* @param data - Event data
|
|
259
|
+
* @param secret - Webhook secret for signing
|
|
260
|
+
* @returns Object with payload and headers for testing
|
|
261
|
+
*/
|
|
262
|
+
async constructTestEvent<T>(
|
|
263
|
+
event: string,
|
|
264
|
+
data: T,
|
|
265
|
+
secret: string
|
|
266
|
+
): Promise<{
|
|
267
|
+
payload: string;
|
|
268
|
+
headers: Record<string, string>;
|
|
269
|
+
}> {
|
|
270
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
271
|
+
const webhookPayload: WebhookPayload<T> = {
|
|
272
|
+
event: event as WebhookPayload<T>['event'],
|
|
273
|
+
timestamp: new Date().toISOString(),
|
|
274
|
+
data,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const payload = JSON.stringify(webhookPayload);
|
|
278
|
+
const signature = await computeSignature(payload, timestamp, secret);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
payload,
|
|
282
|
+
headers: {
|
|
283
|
+
'X-Scell-Signature': `t=${timestamp},v1=${signature}`,
|
|
284
|
+
'X-Scell-Event': event,
|
|
285
|
+
'X-Scell-Delivery': crypto.randomUUID(),
|
|
286
|
+
'Content-Type': 'application/json',
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|