@skillrecordings/sdk 0.2.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/CHANGELOG.md +12 -0
- package/README.md +14 -0
- package/package.json +22 -0
- package/src/__tests__/client.test.ts +351 -0
- package/src/__tests__/handler.test.ts +442 -0
- package/src/__tests__/types.test.ts +121 -0
- package/src/adapter.ts +43 -0
- package/src/client.ts +146 -0
- package/src/handler.ts +271 -0
- package/src/index.ts +19 -0
- package/src/integration.ts +164 -0
- package/src/types.ts +82 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +10 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @skillrecordings/sdk
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 91c7136: Initial public release of the Skill Recordings Support SDK.
|
|
8
|
+
|
|
9
|
+
Provides the integration contract for apps to connect to the support platform:
|
|
10
|
+
- `IntegrationClient` for querying user data and purchases
|
|
11
|
+
- Webhook handler utilities for SDK-to-platform communication
|
|
12
|
+
- Type definitions for the integration interface
|
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @skillrecordings/sdk
|
|
2
|
+
|
|
3
|
+
Integration contract + adapters for external apps.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
- Define the SDK surface for app integrations
|
|
7
|
+
- Provide adapters for supported platforms
|
|
8
|
+
|
|
9
|
+
## Key paths
|
|
10
|
+
- `packages/sdk/src/`
|
|
11
|
+
|
|
12
|
+
## Do / Don’t
|
|
13
|
+
- Do keep adapters thin
|
|
14
|
+
- Don’t embed app-specific logic in core
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skillrecordings/sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./types": "./src/types.ts",
|
|
8
|
+
"./integration": "./src/integration.ts",
|
|
9
|
+
"./adapter": "./src/adapter.ts",
|
|
10
|
+
"./client": "./src/client.ts",
|
|
11
|
+
"./handler": "./src/handler.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"check-types": "tsc --noEmit",
|
|
15
|
+
"test": "vitest --run"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@repo/typescript-config": "*",
|
|
19
|
+
"@types/node": "^22.15.3",
|
|
20
|
+
"typescript": "5.9.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { User, Purchase, ActionResult } from '../integration';
|
|
3
|
+
|
|
4
|
+
// Import will fail until we create the client
|
|
5
|
+
import { IntegrationClient } from '../client';
|
|
6
|
+
|
|
7
|
+
describe('IntegrationClient', () => {
|
|
8
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
9
|
+
let client: IntegrationClient;
|
|
10
|
+
const baseUrl = 'https://app.example.com';
|
|
11
|
+
const webhookSecret = 'whsec_test123';
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
fetchMock = vi.fn();
|
|
15
|
+
global.fetch = fetchMock;
|
|
16
|
+
|
|
17
|
+
client = new IntegrationClient({
|
|
18
|
+
baseUrl,
|
|
19
|
+
webhookSecret,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('constructor', () => {
|
|
28
|
+
it('creates client with baseUrl and secret', () => {
|
|
29
|
+
expect(client).toBeInstanceOf(IntegrationClient);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('strips trailing slash from baseUrl', () => {
|
|
33
|
+
const clientWithSlash = new IntegrationClient({
|
|
34
|
+
baseUrl: 'https://app.example.com/',
|
|
35
|
+
webhookSecret: 'secret',
|
|
36
|
+
});
|
|
37
|
+
expect(clientWithSlash).toBeInstanceOf(IntegrationClient);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('HMAC signature', () => {
|
|
42
|
+
it('signs requests with X-Support-Signature header', async () => {
|
|
43
|
+
const user: User = {
|
|
44
|
+
id: 'usr_123',
|
|
45
|
+
email: 'test@example.com',
|
|
46
|
+
name: 'Test User',
|
|
47
|
+
createdAt: new Date(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
fetchMock.mockResolvedValueOnce({
|
|
51
|
+
ok: true,
|
|
52
|
+
json: async () => user,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await client.lookupUser('test@example.com');
|
|
56
|
+
|
|
57
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
58
|
+
`${baseUrl}/api/support/lookup-user`,
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
headers: expect.objectContaining({
|
|
61
|
+
'X-Support-Signature': expect.stringMatching(/^t=\d+,v1=[a-f0-9]+$/),
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('includes timestamp in signature', async () => {
|
|
68
|
+
fetchMock.mockResolvedValueOnce({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: async () => null,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
await client.lookupUser('test@example.com');
|
|
75
|
+
|
|
76
|
+
const call = fetchMock.mock.calls[0];
|
|
77
|
+
const signature = call?.[1]?.headers?.['X-Support-Signature'];
|
|
78
|
+
expect(signature).toBeDefined();
|
|
79
|
+
const timestampPart = (signature as string).split('t=')[1]?.split(',')[0];
|
|
80
|
+
expect(timestampPart).toBeDefined();
|
|
81
|
+
const timestamp = parseInt(timestampPart as string);
|
|
82
|
+
|
|
83
|
+
// Timestamp should be within 1 second of now
|
|
84
|
+
expect(Math.abs(timestamp - now / 1000)).toBeLessThan(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('lookupUser', () => {
|
|
89
|
+
it('calls /api/support/lookup-user endpoint', async () => {
|
|
90
|
+
const user: User = {
|
|
91
|
+
id: 'usr_123',
|
|
92
|
+
email: 'test@example.com',
|
|
93
|
+
name: 'Test User',
|
|
94
|
+
createdAt: new Date(),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
fetchMock.mockResolvedValueOnce({
|
|
98
|
+
ok: true,
|
|
99
|
+
json: async () => user,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await client.lookupUser('test@example.com');
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual(user);
|
|
105
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
106
|
+
`${baseUrl}/api/support/lookup-user`,
|
|
107
|
+
expect.objectContaining({
|
|
108
|
+
method: 'POST',
|
|
109
|
+
body: JSON.stringify({ email: 'test@example.com' }),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns null when user not found', async () => {
|
|
115
|
+
fetchMock.mockResolvedValueOnce({
|
|
116
|
+
ok: true,
|
|
117
|
+
json: async () => null,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await client.lookupUser('notfound@example.com');
|
|
121
|
+
expect(result).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('getPurchases', () => {
|
|
126
|
+
it('calls /api/support/get-purchases endpoint', async () => {
|
|
127
|
+
const purchases: Purchase[] = [
|
|
128
|
+
{
|
|
129
|
+
id: 'pur_123',
|
|
130
|
+
productId: 'prod_123',
|
|
131
|
+
productName: 'Test Product',
|
|
132
|
+
purchasedAt: new Date(),
|
|
133
|
+
amount: 10000,
|
|
134
|
+
currency: 'usd',
|
|
135
|
+
status: 'active',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
fetchMock.mockResolvedValueOnce({
|
|
140
|
+
ok: true,
|
|
141
|
+
json: async () => purchases,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await client.getPurchases('usr_123');
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual(purchases);
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
148
|
+
`${baseUrl}/api/support/get-purchases`,
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: JSON.stringify({ userId: 'usr_123' }),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns empty array when no purchases', async () => {
|
|
157
|
+
fetchMock.mockResolvedValueOnce({
|
|
158
|
+
ok: true,
|
|
159
|
+
json: async () => [],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await client.getPurchases('usr_123');
|
|
163
|
+
expect(result).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('revokeAccess', () => {
|
|
168
|
+
it('calls /api/support/revoke-access endpoint', async () => {
|
|
169
|
+
const actionResult: ActionResult = { success: true };
|
|
170
|
+
|
|
171
|
+
fetchMock.mockResolvedValueOnce({
|
|
172
|
+
ok: true,
|
|
173
|
+
json: async () => actionResult,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await client.revokeAccess({
|
|
177
|
+
purchaseId: 'pur_123',
|
|
178
|
+
reason: 'Customer requested refund',
|
|
179
|
+
refundId: 're_123',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result).toEqual(actionResult);
|
|
183
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
184
|
+
`${baseUrl}/api/support/revoke-access`,
|
|
185
|
+
expect.objectContaining({
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
purchaseId: 'pur_123',
|
|
189
|
+
reason: 'Customer requested refund',
|
|
190
|
+
refundId: 're_123',
|
|
191
|
+
}),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('transferPurchase', () => {
|
|
198
|
+
it('calls /api/support/transfer-purchase endpoint', async () => {
|
|
199
|
+
const actionResult: ActionResult = { success: true };
|
|
200
|
+
|
|
201
|
+
fetchMock.mockResolvedValueOnce({
|
|
202
|
+
ok: true,
|
|
203
|
+
json: async () => actionResult,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = await client.transferPurchase({
|
|
207
|
+
purchaseId: 'pur_123',
|
|
208
|
+
fromUserId: 'usr_123',
|
|
209
|
+
toEmail: 'new@example.com',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result).toEqual(actionResult);
|
|
213
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
214
|
+
`${baseUrl}/api/support/transfer-purchase`,
|
|
215
|
+
expect.objectContaining({
|
|
216
|
+
method: 'POST',
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
purchaseId: 'pur_123',
|
|
219
|
+
fromUserId: 'usr_123',
|
|
220
|
+
toEmail: 'new@example.com',
|
|
221
|
+
}),
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('generateMagicLink', () => {
|
|
228
|
+
it('calls /api/support/generate-magic-link endpoint', async () => {
|
|
229
|
+
const magicLink = { url: 'https://app.example.com/auth/magic?token=abc123' };
|
|
230
|
+
|
|
231
|
+
fetchMock.mockResolvedValueOnce({
|
|
232
|
+
ok: true,
|
|
233
|
+
json: async () => magicLink,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await client.generateMagicLink({
|
|
237
|
+
email: 'test@example.com',
|
|
238
|
+
expiresIn: 3600,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual(magicLink);
|
|
242
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
243
|
+
`${baseUrl}/api/support/generate-magic-link`,
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
method: 'POST',
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
email: 'test@example.com',
|
|
248
|
+
expiresIn: 3600,
|
|
249
|
+
}),
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('error handling', () => {
|
|
256
|
+
it('throws when response is not ok', async () => {
|
|
257
|
+
fetchMock.mockResolvedValueOnce({
|
|
258
|
+
ok: false,
|
|
259
|
+
status: 500,
|
|
260
|
+
statusText: 'Internal Server Error',
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await expect(client.lookupUser('test@example.com')).rejects.toThrow(
|
|
264
|
+
'Integration request failed: 500 Internal Server Error',
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('includes error message from response when available', async () => {
|
|
269
|
+
fetchMock.mockResolvedValueOnce({
|
|
270
|
+
ok: false,
|
|
271
|
+
status: 400,
|
|
272
|
+
statusText: 'Bad Request',
|
|
273
|
+
json: async () => ({ error: 'Invalid email format' }),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await expect(client.lookupUser('invalid')).rejects.toThrow('Invalid email format');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('handles network errors', async () => {
|
|
280
|
+
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
|
281
|
+
|
|
282
|
+
await expect(client.lookupUser('test@example.com')).rejects.toThrow('Network error');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('optional methods', () => {
|
|
287
|
+
it('calls getSubscriptions when implemented', async () => {
|
|
288
|
+
fetchMock.mockResolvedValueOnce({
|
|
289
|
+
ok: true,
|
|
290
|
+
json: async () => [],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = await client.getSubscriptions?.('usr_123');
|
|
294
|
+
expect(result).toEqual([]);
|
|
295
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
296
|
+
`${baseUrl}/api/support/get-subscriptions`,
|
|
297
|
+
expect.any(Object),
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('calls updateEmail when implemented', async () => {
|
|
302
|
+
fetchMock.mockResolvedValueOnce({
|
|
303
|
+
ok: true,
|
|
304
|
+
json: async () => ({ success: true }),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const result = await client.updateEmail?.({
|
|
308
|
+
userId: 'usr_123',
|
|
309
|
+
newEmail: 'new@example.com',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result).toEqual({ success: true });
|
|
313
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
314
|
+
`${baseUrl}/api/support/update-email`,
|
|
315
|
+
expect.any(Object),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('calls updateName when implemented', async () => {
|
|
320
|
+
fetchMock.mockResolvedValueOnce({
|
|
321
|
+
ok: true,
|
|
322
|
+
json: async () => ({ success: true }),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const result = await client.updateName?.({
|
|
326
|
+
userId: 'usr_123',
|
|
327
|
+
newName: 'New Name',
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result).toEqual({ success: true });
|
|
331
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
332
|
+
`${baseUrl}/api/support/update-name`,
|
|
333
|
+
expect.any(Object),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('calls getClaimedSeats when implemented', async () => {
|
|
338
|
+
fetchMock.mockResolvedValueOnce({
|
|
339
|
+
ok: true,
|
|
340
|
+
json: async () => [],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await client.getClaimedSeats?.('bulk_123');
|
|
344
|
+
expect(result).toEqual([]);
|
|
345
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
346
|
+
`${baseUrl}/api/support/get-claimed-seats`,
|
|
347
|
+
expect.any(Object),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|