@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.
package/src/client.ts DELETED
@@ -1,146 +0,0 @@
1
- import { createHmac } from 'node:crypto'
2
- import type {
3
- ActionResult,
4
- ClaimedSeat,
5
- Purchase,
6
- Subscription,
7
- SupportIntegration,
8
- User,
9
- } from './integration'
10
-
11
- /**
12
- * Client for calling app integration endpoints with HMAC-signed requests.
13
- *
14
- * Used by core to call app-specific support actions (lookupUser, getPurchases, etc.)
15
- * with Stripe-style HMAC-SHA256 signature verification.
16
- *
17
- * @example
18
- * ```typescript
19
- * import { IntegrationClient } from '@skillrecordings/sdk/client'
20
- *
21
- * const client = new IntegrationClient({
22
- * baseUrl: 'https://totaltypescript.com',
23
- * webhookSecret: 'whsec_abc123',
24
- * })
25
- *
26
- * const user = await client.lookupUser('test@example.com')
27
- * ```
28
- */
29
- export class IntegrationClient implements SupportIntegration {
30
- private readonly baseUrl: string
31
- private readonly webhookSecret: string
32
-
33
- constructor(config: { baseUrl: string; webhookSecret: string }) {
34
- // Strip trailing slash for consistent URL construction
35
- this.baseUrl = config.baseUrl.replace(/\/$/, '')
36
- this.webhookSecret = config.webhookSecret
37
- }
38
-
39
- /**
40
- * Generate HMAC-SHA256 signature for request body.
41
- * Format: `t=<timestamp>,v1=<signature>`
42
- *
43
- * Signature is computed as: HMAC-SHA256(timestamp + "." + body, secret)
44
- */
45
- private generateSignature(body: string): string {
46
- const timestamp = Math.floor(Date.now() / 1000)
47
- const signedPayload = `${timestamp}.${body}`
48
- const signature = createHmac('sha256', this.webhookSecret)
49
- .update(signedPayload)
50
- .digest('hex')
51
-
52
- return `t=${timestamp},v1=${signature}`
53
- }
54
-
55
- /**
56
- * Make signed POST request to app integration endpoint.
57
- */
58
- private async request<T>(endpoint: string, payload: unknown): Promise<T> {
59
- const body = JSON.stringify(payload)
60
- const signature = this.generateSignature(body)
61
-
62
- const response = await fetch(`${this.baseUrl}${endpoint}`, {
63
- method: 'POST',
64
- headers: {
65
- 'Content-Type': 'application/json',
66
- 'X-Support-Signature': signature,
67
- },
68
- body,
69
- })
70
-
71
- if (!response.ok) {
72
- // Try to extract error message from response body
73
- let errorMessage: string | undefined
74
- try {
75
- const errorBody = (await response.json()) as { error?: string }
76
- if (errorBody?.error) {
77
- errorMessage = errorBody.error
78
- }
79
- } catch {
80
- // If JSON parsing fails, ignore and use status text
81
- }
82
-
83
- if (errorMessage) {
84
- throw new Error(errorMessage)
85
- }
86
- throw new Error(
87
- `Integration request failed: ${response.status} ${response.statusText}`
88
- )
89
- }
90
-
91
- return (await response.json()) as T
92
- }
93
-
94
- async lookupUser(email: string): Promise<User | null> {
95
- return this.request('/api/support/lookup-user', { email })
96
- }
97
-
98
- async getPurchases(userId: string): Promise<Purchase[]> {
99
- return this.request('/api/support/get-purchases', { userId })
100
- }
101
-
102
- async getSubscriptions(userId: string): Promise<Subscription[]> {
103
- return this.request('/api/support/get-subscriptions', { userId })
104
- }
105
-
106
- async revokeAccess(params: {
107
- purchaseId: string
108
- reason: string
109
- refundId: string
110
- }): Promise<ActionResult> {
111
- return this.request('/api/support/revoke-access', params)
112
- }
113
-
114
- async transferPurchase(params: {
115
- purchaseId: string
116
- fromUserId: string
117
- toEmail: string
118
- }): Promise<ActionResult> {
119
- return this.request('/api/support/transfer-purchase', params)
120
- }
121
-
122
- async generateMagicLink(params: {
123
- email: string
124
- expiresIn: number
125
- }): Promise<{ url: string }> {
126
- return this.request('/api/support/generate-magic-link', params)
127
- }
128
-
129
- async updateEmail(params: {
130
- userId: string
131
- newEmail: string
132
- }): Promise<ActionResult> {
133
- return this.request('/api/support/update-email', params)
134
- }
135
-
136
- async updateName(params: {
137
- userId: string
138
- newName: string
139
- }): Promise<ActionResult> {
140
- return this.request('/api/support/update-name', params)
141
- }
142
-
143
- async getClaimedSeats(bulkCouponId: string): Promise<ClaimedSeat[]> {
144
- return this.request('/api/support/get-claimed-seats', { bulkCouponId })
145
- }
146
- }
package/src/handler.ts DELETED
@@ -1,271 +0,0 @@
1
- import { timingSafeEqual } from 'crypto';
2
- import type { SupportIntegration } from './integration';
3
-
4
- /**
5
- * Configuration for createSupportHandler
6
- */
7
- export interface SupportHandlerConfig {
8
- integration: SupportIntegration;
9
- webhookSecret: string;
10
- }
11
-
12
- /**
13
- * Request body for webhook actions
14
- */
15
- interface WebhookRequest {
16
- action: string;
17
- [key: string]: unknown;
18
- }
19
-
20
- /**
21
- * Creates a Next.js API route handler for SupportIntegration.
22
- * Verifies HMAC-SHA256 signature and routes actions to integration methods.
23
- *
24
- * Signature format: timestamp=1234567890,v1=hex_signature
25
- * Payload to sign: timestamp.JSON.stringify(body)
26
- * Replay protection: 5 minute window
27
- *
28
- * @example
29
- * ```typescript
30
- * import { createSupportHandler } from '@skillrecordings/sdk/handler'
31
- * import { integration } from './integration'
32
- *
33
- * export const POST = createSupportHandler({
34
- * integration,
35
- * webhookSecret: process.env.SUPPORT_WEBHOOK_SECRET!,
36
- * })
37
- * ```
38
- */
39
- export function createSupportHandler(
40
- config: SupportHandlerConfig,
41
- ): (request: Request) => Promise<Response> {
42
- const { integration, webhookSecret } = config;
43
-
44
- return async function handler(request: Request): Promise<Response> {
45
- try {
46
- // 1. Extract signature header
47
- const signatureHeader = request.headers.get('x-support-signature');
48
- if (!signatureHeader) {
49
- return jsonResponse({ error: 'Missing signature header' }, 401);
50
- }
51
-
52
- // 2. Parse signature header (format: timestamp=1234567890,v1=hex_signature)
53
- const parts = signatureHeader.split(',');
54
- const timestampPart = parts.find((p) => p.startsWith('timestamp='));
55
- const signaturePart = parts.find((p) => p.startsWith('v1='));
56
-
57
- if (!timestampPart || !signaturePart) {
58
- return jsonResponse({ error: 'Invalid signature format' }, 401);
59
- }
60
-
61
- const timestampValue = timestampPart.split('=')[1];
62
- const signatureValue = signaturePart.split('=')[1];
63
-
64
- if (!timestampValue || !signatureValue) {
65
- return jsonResponse({ error: 'Invalid signature format' }, 401);
66
- }
67
-
68
- const timestamp = parseInt(timestampValue, 10);
69
- const receivedSignature = signatureValue;
70
-
71
- // 3. Verify timestamp (replay protection - 5 minute window)
72
- const now = Math.floor(Date.now() / 1000);
73
- const maxAge = 300; // 5 minutes in seconds
74
- if (now - timestamp > maxAge) {
75
- return jsonResponse({ error: 'Signature expired' }, 401);
76
- }
77
-
78
- // 4. Read and parse body
79
- const bodyText = await request.text();
80
- let body: WebhookRequest;
81
-
82
- try {
83
- body = JSON.parse(bodyText);
84
- } catch (err) {
85
- return jsonResponse({ error: 'Invalid JSON body' }, 400);
86
- }
87
-
88
- // 5. Compute expected signature
89
- const crypto = await import('crypto');
90
- const payload = `${timestamp}.${bodyText}`;
91
- const expectedSignature = crypto
92
- .createHmac('sha256', webhookSecret)
93
- .update(payload)
94
- .digest('hex');
95
-
96
- // 6. Timing-safe comparison to prevent timing attacks
97
- if (
98
- !timingSafeEqual(
99
- Buffer.from(receivedSignature),
100
- Buffer.from(expectedSignature),
101
- )
102
- ) {
103
- return jsonResponse({ error: 'Invalid signature' }, 401);
104
- }
105
-
106
- // 7. Extract action field
107
- const { action } = body;
108
- if (!action || typeof action !== 'string') {
109
- return jsonResponse({ error: 'Missing action field' }, 400);
110
- }
111
-
112
- // 8. Route to integration method
113
- const result = await routeAction(integration, action, body);
114
- return jsonResponse(result.data, result.status);
115
- } catch (err) {
116
- const message = err instanceof Error ? err.message : 'Unknown error';
117
- return jsonResponse({ error: `Internal error: ${message}` }, 500);
118
- }
119
- };
120
- }
121
-
122
- /**
123
- * Routes action to appropriate integration method
124
- */
125
- async function routeAction(
126
- integration: SupportIntegration,
127
- action: string,
128
- body: WebhookRequest,
129
- ): Promise<{ data: unknown; status: number }> {
130
- try {
131
- switch (action) {
132
- case 'lookupUser': {
133
- const email = (body as unknown as { email: string }).email;
134
- const result = await integration.lookupUser(email);
135
- return { data: result, status: 200 };
136
- }
137
-
138
- case 'getPurchases': {
139
- const userId = (body as unknown as { userId: string }).userId;
140
- const result = await integration.getPurchases(userId);
141
- return { data: result, status: 200 };
142
- }
143
-
144
- case 'revokeAccess': {
145
- const params = body as unknown as {
146
- purchaseId: string;
147
- reason: string;
148
- refundId: string;
149
- };
150
- const result = await integration.revokeAccess({
151
- purchaseId: params.purchaseId,
152
- reason: params.reason,
153
- refundId: params.refundId,
154
- });
155
- return { data: result, status: 200 };
156
- }
157
-
158
- case 'transferPurchase': {
159
- const params = body as unknown as {
160
- purchaseId: string;
161
- fromUserId: string;
162
- toEmail: string;
163
- };
164
- const result = await integration.transferPurchase({
165
- purchaseId: params.purchaseId,
166
- fromUserId: params.fromUserId,
167
- toEmail: params.toEmail,
168
- });
169
- return { data: result, status: 200 };
170
- }
171
-
172
- case 'generateMagicLink': {
173
- const params = body as unknown as {
174
- email: string;
175
- expiresIn: number;
176
- };
177
- const result = await integration.generateMagicLink({
178
- email: params.email,
179
- expiresIn: params.expiresIn,
180
- });
181
- return { data: result, status: 200 };
182
- }
183
-
184
- // Optional methods
185
- case 'getSubscriptions': {
186
- if (!integration.getSubscriptions) {
187
- return {
188
- data: { error: 'Method not implemented: getSubscriptions' },
189
- status: 501,
190
- };
191
- }
192
- const userId = (body as unknown as { userId: string }).userId;
193
- const result = await integration.getSubscriptions(userId);
194
- return { data: result, status: 200 };
195
- }
196
-
197
- case 'updateEmail': {
198
- if (!integration.updateEmail) {
199
- return {
200
- data: { error: 'Method not implemented: updateEmail' },
201
- status: 501,
202
- };
203
- }
204
- const params = body as unknown as {
205
- userId: string;
206
- newEmail: string;
207
- };
208
- const result = await integration.updateEmail({
209
- userId: params.userId,
210
- newEmail: params.newEmail,
211
- });
212
- return { data: result, status: 200 };
213
- }
214
-
215
- case 'updateName': {
216
- if (!integration.updateName) {
217
- return {
218
- data: { error: 'Method not implemented: updateName' },
219
- status: 501,
220
- };
221
- }
222
- const params = body as unknown as {
223
- userId: string;
224
- newName: string;
225
- };
226
- const result = await integration.updateName({
227
- userId: params.userId,
228
- newName: params.newName,
229
- });
230
- return { data: result, status: 200 };
231
- }
232
-
233
- case 'getClaimedSeats': {
234
- if (!integration.getClaimedSeats) {
235
- return {
236
- data: { error: 'Method not implemented: getClaimedSeats' },
237
- status: 501,
238
- };
239
- }
240
- const bulkCouponId = (body as unknown as { bulkCouponId: string })
241
- .bulkCouponId;
242
- const result = await integration.getClaimedSeats(bulkCouponId);
243
- return { data: result, status: 200 };
244
- }
245
-
246
- default:
247
- return {
248
- data: { error: `Unknown action: ${action}` },
249
- status: 400,
250
- };
251
- }
252
- } catch (err) {
253
- const message = err instanceof Error ? err.message : 'Unknown error';
254
- return {
255
- data: { error: message },
256
- status: 500,
257
- };
258
- }
259
- }
260
-
261
- /**
262
- * Helper to create JSON responses
263
- */
264
- function jsonResponse(data: unknown, status: number): Response {
265
- return new Response(JSON.stringify(data), {
266
- status,
267
- headers: {
268
- 'content-type': 'application/json',
269
- },
270
- });
271
- }
package/src/index.ts DELETED
@@ -1,19 +0,0 @@
1
- // New SupportIntegration interface (primary)
2
- export type { SupportIntegration } from './integration';
3
-
4
- // Core types
5
- export type {
6
- User,
7
- Purchase,
8
- Subscription,
9
- ActionResult,
10
- ClaimedSeat,
11
- } from './types';
12
-
13
- // Deprecated exports (backwards compatibility)
14
- export type { AppAdapter } from './adapter';
15
- export type {
16
- Customer,
17
- RefundRequest,
18
- RefundResult,
19
- } from './types';
@@ -1,164 +0,0 @@
1
- import type {
2
- User,
3
- Purchase,
4
- Subscription,
5
- ActionResult,
6
- ClaimedSeat,
7
- } from './types';
8
-
9
- // Re-export types for convenience
10
- export type {
11
- User,
12
- Purchase,
13
- Subscription,
14
- ActionResult,
15
- ClaimedSeat,
16
- };
17
-
18
- /**
19
- * SupportIntegration interface that apps must implement.
20
- *
21
- * Each app (egghead, Total TypeScript, etc.) implements this interface
22
- * to provide user lookup, purchase/subscription management, and support actions.
23
- *
24
- * The support platform calls these methods via IntegrationClient with HMAC auth.
25
- *
26
- * @example
27
- * ```typescript
28
- * import type { SupportIntegration } from '@skillrecordings/sdk/integration'
29
- *
30
- * const integration: SupportIntegration = {
31
- * async lookupUser(email) {
32
- * return db.user.findUnique({ where: { email } })
33
- * },
34
- * async getPurchases(userId) {
35
- * return db.purchase.findMany({ where: { userId } })
36
- * },
37
- * async revokeAccess({ purchaseId, reason, refundId }) {
38
- * await db.purchase.update({
39
- * where: { id: purchaseId },
40
- * data: { status: 'refunded', refundReason: reason, stripeRefundId: refundId }
41
- * })
42
- * return { success: true }
43
- * },
44
- * async transferPurchase({ purchaseId, fromUserId, toEmail }) {
45
- * const toUser = await db.user.findUnique({ where: { email: toEmail } })
46
- * await db.purchase.update({
47
- * where: { id: purchaseId },
48
- * data: { userId: toUser.id }
49
- * })
50
- * return { success: true }
51
- * },
52
- * async generateMagicLink({ email, expiresIn }) {
53
- * const token = await createMagicToken(email, expiresIn)
54
- * return { url: `${APP_URL}/auth/magic?token=${token}` }
55
- * },
56
- * }
57
- * ```
58
- */
59
- export interface SupportIntegration {
60
- /**
61
- * Look up user by email address.
62
- * Called by the agent to fetch user context at conversation start.
63
- *
64
- * @param email - User's email address
65
- * @returns User if found, null otherwise
66
- */
67
- lookupUser(email: string): Promise<User | null>;
68
-
69
- /**
70
- * Fetch all purchases for a given user.
71
- * Used by agent to display purchase history and validate refund eligibility.
72
- *
73
- * @param userId - User's unique identifier
74
- * @returns Array of purchases, empty if none found
75
- */
76
- getPurchases(userId: string): Promise<Purchase[]>;
77
-
78
- /**
79
- * Fetch active subscriptions for a user.
80
- * Optional method - only implement if app supports recurring billing.
81
- *
82
- * @param userId - User's unique identifier
83
- * @returns Array of subscriptions, empty if none found
84
- */
85
- getSubscriptions?(userId: string): Promise<Subscription[]>;
86
-
87
- /**
88
- * Revoke access to a product after refund.
89
- * Called after Stripe refund succeeds to remove product access.
90
- *
91
- * @param params.purchaseId - Purchase to revoke
92
- * @param params.reason - Refund reason for audit trail
93
- * @param params.refundId - Stripe refund ID
94
- * @returns ActionResult indicating success/failure
95
- */
96
- revokeAccess(params: {
97
- purchaseId: string;
98
- reason: string;
99
- refundId: string;
100
- }): Promise<ActionResult>;
101
-
102
- /**
103
- * Transfer purchase to a different user.
104
- * Updates purchase ownership and moves product access.
105
- *
106
- * @param params.purchaseId - Purchase to transfer
107
- * @param params.fromUserId - Current owner's ID
108
- * @param params.toEmail - New owner's email address
109
- * @returns ActionResult indicating success/failure
110
- */
111
- transferPurchase(params: {
112
- purchaseId: string;
113
- fromUserId: string;
114
- toEmail: string;
115
- }): Promise<ActionResult>;
116
-
117
- /**
118
- * Generate a magic link for passwordless login.
119
- * Used by agent to send login links during support conversations.
120
- *
121
- * @param params.email - User's email address
122
- * @param params.expiresIn - Expiration time in seconds (default 3600)
123
- * @returns Object with magic link URL
124
- */
125
- generateMagicLink(params: {
126
- email: string;
127
- expiresIn: number;
128
- }): Promise<{ url: string }>;
129
-
130
- /**
131
- * Update user's email address.
132
- * Optional method - not all apps support email changes.
133
- *
134
- * @param params.userId - User's unique identifier
135
- * @param params.newEmail - New email address
136
- * @returns ActionResult indicating success/failure
137
- */
138
- updateEmail?(params: {
139
- userId: string;
140
- newEmail: string;
141
- }): Promise<ActionResult>;
142
-
143
- /**
144
- * Update user's display name.
145
- * Optional method - not all apps support name changes.
146
- *
147
- * @param params.userId - User's unique identifier
148
- * @param params.newName - New display name
149
- * @returns ActionResult indicating success/failure
150
- */
151
- updateName?(params: {
152
- userId: string;
153
- newName: string;
154
- }): Promise<ActionResult>;
155
-
156
- /**
157
- * Get all claimed seats for a team/bulk purchase.
158
- * Optional method - only implement for apps with team features.
159
- *
160
- * @param bulkCouponId - Bulk coupon/license identifier
161
- * @returns Array of claimed seats with user info
162
- */
163
- getClaimedSeats?(bulkCouponId: string): Promise<ClaimedSeat[]>;
164
- }
package/src/types.ts DELETED
@@ -1,82 +0,0 @@
1
- /**
2
- * User entity returned by app integration.
3
- * Replaces Customer for consistency with SupportIntegration interface.
4
- */
5
- export interface User {
6
- id: string;
7
- email: string;
8
- name?: string;
9
- createdAt: Date;
10
- }
11
-
12
- /**
13
- * @deprecated Use User instead. Kept for backwards compatibility.
14
- */
15
- export type Customer = User;
16
-
17
- /**
18
- * Purchase record with product and payment details.
19
- * Used by agent tools to display purchase history.
20
- */
21
- export interface Purchase {
22
- id: string;
23
- productId: string;
24
- productName: string;
25
- purchasedAt: Date;
26
- amount: number;
27
- currency: string;
28
- stripeChargeId?: string;
29
- status: 'active' | 'refunded' | 'transferred';
30
- }
31
-
32
- /**
33
- * Subscription entity for recurring billing.
34
- * Optional method - apps may not support subscriptions.
35
- */
36
- export interface Subscription {
37
- id: string;
38
- productId: string;
39
- productName: string;
40
- status: 'active' | 'cancelled' | 'expired' | 'paused';
41
- currentPeriodStart: Date;
42
- currentPeriodEnd: Date;
43
- cancelAtPeriodEnd: boolean;
44
- }
45
-
46
- /**
47
- * Generic result type for mutations (refund, transfer, updates).
48
- */
49
- export interface ActionResult {
50
- success: boolean;
51
- error?: string;
52
- }
53
-
54
- /**
55
- * Claimed seat for team/bulk purchases.
56
- * Used by getClaimedSeats optional method.
57
- */
58
- export interface ClaimedSeat {
59
- userId: string;
60
- email: string;
61
- claimedAt: Date;
62
- }
63
-
64
- /**
65
- * Refund request payload.
66
- * @deprecated Use revokeAccess via SupportIntegration instead.
67
- */
68
- export interface RefundRequest {
69
- purchaseId: string;
70
- reason: string;
71
- amount?: number;
72
- }
73
-
74
- /**
75
- * Refund result.
76
- * @deprecated Use ActionResult instead.
77
- */
78
- export interface RefundResult {
79
- success: boolean;
80
- refundId?: string;
81
- error?: string;
82
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "@repo/typescript-config/base.json",
3
- "compilerOptions": {
4
- "outDir": "dist"
5
- },
6
- "include": ["src"],
7
- "exclude": ["node_modules", "dist"]
8
- }
package/vitest.config.ts DELETED
@@ -1,10 +0,0 @@
1
- import { defineConfig } from 'vitest/config'
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: true,
6
- environment: 'node',
7
- include: ['src/**/*.test.ts'],
8
- passWithNoTests: true,
9
- },
10
- })