@skillrecordings/sdk 0.2.0 → 0.2.1

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,442 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { createSupportHandler } from '../handler'
3
- import type { SupportIntegration } from '../integration'
4
-
5
- describe('createSupportHandler', () => {
6
- const mockIntegration: SupportIntegration = {
7
- lookupUser: vi.fn(async (email: string) => ({
8
- id: 'usr_123',
9
- email,
10
- name: 'Test User',
11
- createdAt: new Date(),
12
- })),
13
- getPurchases: vi.fn(async (userId: string) => [
14
- {
15
- id: 'pur_123',
16
- productId: 'prod_123',
17
- productName: 'Test Product',
18
- purchasedAt: new Date(),
19
- amount: 10000,
20
- currency: 'usd',
21
- status: 'active' as const,
22
- },
23
- ]),
24
- revokeAccess: vi.fn(async (params) => ({ success: true })),
25
- transferPurchase: vi.fn(async (params) => ({ success: true })),
26
- generateMagicLink: vi.fn(async (params) => ({
27
- url: 'https://example.com/magic?token=abc123',
28
- })),
29
- }
30
-
31
- const webhookSecret = 'whsec_test123'
32
-
33
- beforeEach(() => {
34
- vi.clearAllMocks()
35
- })
36
-
37
- function createSignature(
38
- timestamp: number,
39
- body: string,
40
- secret: string
41
- ): string {
42
- const crypto = require('crypto')
43
- const payload = `${timestamp}.${body}`
44
- const signature = crypto
45
- .createHmac('sha256', secret)
46
- .update(payload)
47
- .digest('hex')
48
- return `timestamp=${timestamp},v1=${signature}`
49
- }
50
-
51
- function createRequest(
52
- body: Record<string, unknown>,
53
- options: {
54
- timestamp?: number
55
- secret?: string
56
- skipSignature?: boolean
57
- malformedSignature?: string
58
- } = {}
59
- ): Request {
60
- const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000)
61
- const bodyString = JSON.stringify(body)
62
- const secret = options.secret ?? webhookSecret
63
- const signature =
64
- options.malformedSignature ??
65
- (options.skipSignature
66
- ? ''
67
- : createSignature(timestamp, bodyString, secret))
68
-
69
- const headers = new Headers({
70
- 'content-type': 'application/json',
71
- })
72
-
73
- if (signature) {
74
- headers.set('x-support-signature', signature)
75
- }
76
-
77
- return new Request('http://localhost:3000/api/support', {
78
- method: 'POST',
79
- headers,
80
- body: bodyString,
81
- })
82
- }
83
-
84
- describe('signature verification', () => {
85
- it('accepts valid HMAC signature', async () => {
86
- const handler = createSupportHandler({
87
- integration: mockIntegration,
88
- webhookSecret,
89
- })
90
-
91
- const request = createRequest({
92
- action: 'lookupUser',
93
- email: 'test@example.com',
94
- })
95
-
96
- const response = await handler(request)
97
- expect(response.status).toBe(200)
98
-
99
- const data = (await response.json()) as Record<string, unknown>
100
- expect(data).toMatchObject({
101
- id: 'usr_123',
102
- email: 'test@example.com',
103
- })
104
- })
105
-
106
- it('rejects missing signature header', async () => {
107
- const handler = createSupportHandler({
108
- integration: mockIntegration,
109
- webhookSecret,
110
- })
111
-
112
- const request = createRequest(
113
- { action: 'lookupUser', email: 'test@example.com' },
114
- { skipSignature: true }
115
- )
116
-
117
- const response = await handler(request)
118
- expect(response.status).toBe(401)
119
-
120
- const data = (await response.json()) as Record<string, unknown>
121
- expect(data.error).toBe('Missing signature header')
122
- })
123
-
124
- it('rejects malformed signature header', async () => {
125
- const handler = createSupportHandler({
126
- integration: mockIntegration,
127
- webhookSecret,
128
- })
129
-
130
- const request = createRequest(
131
- { action: 'lookupUser', email: 'test@example.com' },
132
- { malformedSignature: 'invalid_format' }
133
- )
134
-
135
- const response = await handler(request)
136
- expect(response.status).toBe(401)
137
-
138
- const data = (await response.json()) as Record<string, unknown>
139
- expect(data.error).toBe('Invalid signature format')
140
- })
141
-
142
- it('rejects invalid HMAC signature', async () => {
143
- const handler = createSupportHandler({
144
- integration: mockIntegration,
145
- webhookSecret,
146
- })
147
-
148
- const request = createRequest(
149
- { action: 'lookupUser', email: 'test@example.com' },
150
- { secret: 'wrong_secret' }
151
- )
152
-
153
- const response = await handler(request)
154
- expect(response.status).toBe(401)
155
-
156
- const data = (await response.json()) as Record<string, unknown>
157
- expect(data.error).toBe('Invalid signature')
158
- })
159
-
160
- it('rejects replay attacks (timestamp > 5 minutes old)', async () => {
161
- const handler = createSupportHandler({
162
- integration: mockIntegration,
163
- webhookSecret,
164
- })
165
-
166
- const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 301 // 5 minutes + 1 second
167
- const request = createRequest(
168
- { action: 'lookupUser', email: 'test@example.com' },
169
- { timestamp: fiveMinutesAgo }
170
- )
171
-
172
- const response = await handler(request)
173
- expect(response.status).toBe(401)
174
-
175
- const data = (await response.json()) as Record<string, unknown>
176
- expect(data.error).toBe('Signature expired')
177
- })
178
-
179
- it('accepts timestamp within 5 minute window', async () => {
180
- const handler = createSupportHandler({
181
- integration: mockIntegration,
182
- webhookSecret,
183
- })
184
-
185
- const fourMinutesAgo = Math.floor(Date.now() / 1000) - 240 // 4 minutes
186
- const request = createRequest(
187
- { action: 'lookupUser', email: 'test@example.com' },
188
- { timestamp: fourMinutesAgo }
189
- )
190
-
191
- const response = await handler(request)
192
- expect(response.status).toBe(200)
193
- })
194
- })
195
-
196
- describe('action routing', () => {
197
- it('routes lookupUser action', async () => {
198
- const handler = createSupportHandler({
199
- integration: mockIntegration,
200
- webhookSecret,
201
- })
202
-
203
- const request = createRequest({
204
- action: 'lookupUser',
205
- email: 'test@example.com',
206
- })
207
-
208
- const response = await handler(request)
209
- expect(response.status).toBe(200)
210
-
211
- expect(mockIntegration.lookupUser).toHaveBeenCalledWith(
212
- 'test@example.com'
213
- )
214
- })
215
-
216
- it('routes getPurchases action', async () => {
217
- const handler = createSupportHandler({
218
- integration: mockIntegration,
219
- webhookSecret,
220
- })
221
-
222
- const request = createRequest({
223
- action: 'getPurchases',
224
- userId: 'usr_123',
225
- })
226
-
227
- const response = await handler(request)
228
- expect(response.status).toBe(200)
229
-
230
- expect(mockIntegration.getPurchases).toHaveBeenCalledWith('usr_123')
231
- })
232
-
233
- it('routes revokeAccess action', async () => {
234
- const handler = createSupportHandler({
235
- integration: mockIntegration,
236
- webhookSecret,
237
- })
238
-
239
- const request = createRequest({
240
- action: 'revokeAccess',
241
- purchaseId: 'pur_123',
242
- reason: 'Customer request',
243
- refundId: 're_123',
244
- })
245
-
246
- const response = await handler(request)
247
- expect(response.status).toBe(200)
248
-
249
- expect(mockIntegration.revokeAccess).toHaveBeenCalledWith({
250
- purchaseId: 'pur_123',
251
- reason: 'Customer request',
252
- refundId: 're_123',
253
- })
254
- })
255
-
256
- it('routes transferPurchase action', async () => {
257
- const handler = createSupportHandler({
258
- integration: mockIntegration,
259
- webhookSecret,
260
- })
261
-
262
- const request = createRequest({
263
- action: 'transferPurchase',
264
- purchaseId: 'pur_123',
265
- fromUserId: 'usr_123',
266
- toEmail: 'newuser@example.com',
267
- })
268
-
269
- const response = await handler(request)
270
- expect(response.status).toBe(200)
271
-
272
- expect(mockIntegration.transferPurchase).toHaveBeenCalledWith({
273
- purchaseId: 'pur_123',
274
- fromUserId: 'usr_123',
275
- toEmail: 'newuser@example.com',
276
- })
277
- })
278
-
279
- it('routes generateMagicLink action', async () => {
280
- const handler = createSupportHandler({
281
- integration: mockIntegration,
282
- webhookSecret,
283
- })
284
-
285
- const request = createRequest({
286
- action: 'generateMagicLink',
287
- email: 'test@example.com',
288
- expiresIn: 3600,
289
- })
290
-
291
- const response = await handler(request)
292
- expect(response.status).toBe(200)
293
-
294
- expect(mockIntegration.generateMagicLink).toHaveBeenCalledWith({
295
- email: 'test@example.com',
296
- expiresIn: 3600,
297
- })
298
- })
299
-
300
- it('rejects unknown action', async () => {
301
- const handler = createSupportHandler({
302
- integration: mockIntegration,
303
- webhookSecret,
304
- })
305
-
306
- const request = createRequest({
307
- action: 'unknownAction',
308
- foo: 'bar',
309
- })
310
-
311
- const response = await handler(request)
312
- expect(response.status).toBe(400)
313
-
314
- const data = (await response.json()) as Record<string, unknown>
315
- expect(data.error).toBe('Unknown action: unknownAction')
316
- })
317
-
318
- it('rejects missing action field', async () => {
319
- const handler = createSupportHandler({
320
- integration: mockIntegration,
321
- webhookSecret,
322
- })
323
-
324
- const request = createRequest({
325
- email: 'test@example.com',
326
- })
327
-
328
- const response = await handler(request)
329
- expect(response.status).toBe(400)
330
-
331
- const data = (await response.json()) as Record<string, unknown>
332
- expect(data.error).toBe('Missing action field')
333
- })
334
- })
335
-
336
- describe('optional methods', () => {
337
- it('routes getSubscriptions when implemented', async () => {
338
- const integrationWithSubscriptions: SupportIntegration = {
339
- ...mockIntegration,
340
- getSubscriptions: vi.fn(async (userId: string) => [
341
- {
342
- id: 'sub_123',
343
- productId: 'prod_123',
344
- productName: 'Monthly Subscription',
345
- status: 'active' as const,
346
- currentPeriodStart: new Date(),
347
- currentPeriodEnd: new Date(),
348
- cancelAtPeriodEnd: false,
349
- },
350
- ]),
351
- }
352
-
353
- const handler = createSupportHandler({
354
- integration: integrationWithSubscriptions,
355
- webhookSecret,
356
- })
357
-
358
- const request = createRequest({
359
- action: 'getSubscriptions',
360
- userId: 'usr_123',
361
- })
362
-
363
- const response = await handler(request)
364
- expect(response.status).toBe(200)
365
-
366
- expect(
367
- integrationWithSubscriptions.getSubscriptions
368
- ).toHaveBeenCalledWith('usr_123')
369
- })
370
-
371
- it('returns 501 for optional method not implemented', async () => {
372
- const handler = createSupportHandler({
373
- integration: mockIntegration,
374
- webhookSecret,
375
- })
376
-
377
- const request = createRequest({
378
- action: 'getSubscriptions',
379
- userId: 'usr_123',
380
- })
381
-
382
- const response = await handler(request)
383
- expect(response.status).toBe(501)
384
-
385
- const data = (await response.json()) as Record<string, unknown>
386
- expect(data.error).toBe('Method not implemented: getSubscriptions')
387
- })
388
- })
389
-
390
- describe('error handling', () => {
391
- it('handles integration method errors', async () => {
392
- const failingIntegration: SupportIntegration = {
393
- ...mockIntegration,
394
- lookupUser: vi.fn(async () => {
395
- throw new Error('Database connection failed')
396
- }),
397
- }
398
-
399
- const handler = createSupportHandler({
400
- integration: failingIntegration,
401
- webhookSecret,
402
- })
403
-
404
- const request = createRequest({
405
- action: 'lookupUser',
406
- email: 'test@example.com',
407
- })
408
-
409
- const response = await handler(request)
410
- expect(response.status).toBe(500)
411
-
412
- const data = (await response.json()) as Record<string, unknown>
413
- expect(data.error).toContain('Database connection failed')
414
- })
415
-
416
- it('handles malformed JSON body', async () => {
417
- const handler = createSupportHandler({
418
- integration: mockIntegration,
419
- webhookSecret,
420
- })
421
-
422
- const timestamp = Math.floor(Date.now() / 1000)
423
- const malformedBody = 'not valid json'
424
- const signature = createSignature(timestamp, malformedBody, webhookSecret)
425
-
426
- const request = new Request('http://localhost:3000/api/support', {
427
- method: 'POST',
428
- headers: {
429
- 'content-type': 'application/json',
430
- 'x-support-signature': signature,
431
- },
432
- body: malformedBody,
433
- })
434
-
435
- const response = await handler(request)
436
- expect(response.status).toBe(400)
437
-
438
- const data = (await response.json()) as Record<string, unknown>
439
- expect(data.error).toContain('Invalid JSON')
440
- })
441
- })
442
- })
@@ -1,121 +0,0 @@
1
- import { describe, it, expectTypeOf } from 'vitest';
2
- import type {
3
- SupportIntegration,
4
- User,
5
- Purchase,
6
- Subscription,
7
- ActionResult,
8
- ClaimedSeat,
9
- } from '../integration';
10
-
11
- describe('SDK Types', () => {
12
- it('SupportIntegration has required methods', () => {
13
- const integration: SupportIntegration = {
14
- lookupUser: async (email: string) => null as User | null,
15
- getPurchases: async (userId: string) => [] as Purchase[],
16
- revokeAccess: async (params) => ({ success: true }),
17
- transferPurchase: async (params) => ({ success: true }),
18
- generateMagicLink: async (params) => ({ url: '' }),
19
- };
20
-
21
- expectTypeOf(integration.lookupUser).toBeFunction();
22
- expectTypeOf(integration.getPurchases).toBeFunction();
23
- expectTypeOf(integration.revokeAccess).toBeFunction();
24
- expectTypeOf(integration.transferPurchase).toBeFunction();
25
- expectTypeOf(integration.generateMagicLink).toBeFunction();
26
- });
27
-
28
- it('SupportIntegration has optional methods', () => {
29
- const integration: SupportIntegration = {
30
- lookupUser: async (email: string) => null as User | null,
31
- getPurchases: async (userId: string) => [] as Purchase[],
32
- revokeAccess: async (params) => ({ success: true }),
33
- transferPurchase: async (params) => ({ success: true }),
34
- generateMagicLink: async (params) => ({ url: '' }),
35
- // Optional methods
36
- getSubscriptions: async (userId: string) => [] as Subscription[],
37
- updateEmail: async (params) => ({ success: true }),
38
- updateName: async (params) => ({ success: true }),
39
- getClaimedSeats: async (bulkCouponId: string) => [] as ClaimedSeat[],
40
- };
41
-
42
- if (integration.getSubscriptions) {
43
- expectTypeOf(integration.getSubscriptions).toBeFunction();
44
- }
45
- if (integration.updateEmail) {
46
- expectTypeOf(integration.updateEmail).toBeFunction();
47
- }
48
- if (integration.updateName) {
49
- expectTypeOf(integration.updateName).toBeFunction();
50
- }
51
- if (integration.getClaimedSeats) {
52
- expectTypeOf(integration.getClaimedSeats).toBeFunction();
53
- }
54
- });
55
-
56
- it('User type has required fields', () => {
57
- const user: User = {
58
- id: 'usr_123',
59
- email: 'test@example.com',
60
- name: 'Test User',
61
- createdAt: new Date(),
62
- };
63
-
64
- expectTypeOf(user).toMatchTypeOf<User>();
65
- });
66
-
67
- it('Purchase type includes stripeChargeId', () => {
68
- const purchase: Purchase = {
69
- id: 'pur_123',
70
- productId: 'prod_123',
71
- productName: 'Test Product',
72
- purchasedAt: new Date(),
73
- amount: 10000,
74
- currency: 'usd',
75
- stripeChargeId: 'ch_123',
76
- status: 'active',
77
- };
78
-
79
- expectTypeOf(purchase.stripeChargeId).toMatchTypeOf<string | undefined>();
80
- expectTypeOf(purchase.status).toMatchTypeOf<
81
- 'active' | 'refunded' | 'transferred'
82
- >();
83
- });
84
-
85
- it('ActionResult has success and optional error', () => {
86
- const success: ActionResult = { success: true };
87
- const failure: ActionResult = {
88
- success: false,
89
- error: 'Something went wrong',
90
- };
91
-
92
- expectTypeOf(success).toMatchTypeOf<ActionResult>();
93
- expectTypeOf(failure).toMatchTypeOf<ActionResult>();
94
- });
95
-
96
- it('Subscription type has all required fields', () => {
97
- const subscription: Subscription = {
98
- id: 'sub_123',
99
- productId: 'prod_123',
100
- productName: 'Test Subscription',
101
- status: 'active',
102
- currentPeriodStart: new Date(),
103
- currentPeriodEnd: new Date(),
104
- cancelAtPeriodEnd: false,
105
- };
106
-
107
- expectTypeOf(subscription.status).toMatchTypeOf<
108
- 'active' | 'cancelled' | 'expired' | 'paused'
109
- >();
110
- });
111
-
112
- it('ClaimedSeat has user info and timestamp', () => {
113
- const seat: ClaimedSeat = {
114
- userId: 'usr_123',
115
- email: 'test@example.com',
116
- claimedAt: new Date(),
117
- };
118
-
119
- expectTypeOf(seat).toMatchTypeOf<ClaimedSeat>();
120
- });
121
- });
package/src/adapter.ts DELETED
@@ -1,43 +0,0 @@
1
- import type { Customer, Purchase, RefundRequest, RefundResult } from './types';
2
-
3
- /**
4
- * Base adapter interface that apps must implement to integrate with the support platform.
5
- *
6
- * @deprecated Use SupportIntegration interface from './integration' instead.
7
- * This interface is kept for backwards compatibility during migration.
8
- *
9
- * Migration path:
10
- * - Replace AppAdapter with SupportIntegration
11
- * - Rename getCustomer to lookupUser
12
- * - Replace processRefund with revokeAccess
13
- * - Add required methods: transferPurchase, generateMagicLink
14
- *
15
- * @see {@link SupportIntegration} for the new interface
16
- *
17
- * Each app (egghead, Total TypeScript, etc.) provides:
18
- * - Customer lookup by email
19
- * - Purchase history retrieval
20
- * - Refund processing capabilities
21
- */
22
- export interface AppAdapter {
23
- /**
24
- * Fetch customer by email address
25
- * @deprecated Use lookupUser from SupportIntegration
26
- * @returns Customer if found, null otherwise
27
- */
28
- getCustomer(email: string): Promise<Customer | null>;
29
-
30
- /**
31
- * Fetch all purchases for a given customer
32
- * @deprecated Use getPurchases from SupportIntegration (same signature)
33
- * @returns Array of purchases, empty if none found
34
- */
35
- getPurchases(customerId: string): Promise<Purchase[]>;
36
-
37
- /**
38
- * Process a refund for a purchase
39
- * @deprecated Use revokeAccess from SupportIntegration instead
40
- * @returns RefundResult indicating success/failure
41
- */
42
- processRefund(request: RefundRequest): Promise<RefundResult>;
43
- }