@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.
@@ -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
+ };