@slotchain/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,337 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { jwtDecode } from 'jwt-decode';
3
+ import { SlotlyAuthError } from '../errors';
4
+
5
+ /**
6
+ * Authentication context extracted from validated Slotly request
7
+ */
8
+ export interface SlotlyAuthContext {
9
+ clientKey: string;
10
+ userId?: string;
11
+ tenantId?: string;
12
+ permissions?: string[];
13
+ tokenClaims?: Record<string, any>;
14
+ }
15
+
16
+ /**
17
+ * Extended Next.js request with Slotly context
18
+ */
19
+ export interface SlotlyRequest extends NextApiRequest {
20
+ slotlyContext: SlotlyAuthContext;
21
+ }
22
+
23
+ /**
24
+ * Next.js API route handler type
25
+ */
26
+ export type NextApiHandler = (
27
+ req: NextApiRequest,
28
+ res: NextApiResponse
29
+ ) => Promise<void> | void;
30
+
31
+ /**
32
+ * Mock allowlist for client keys
33
+ *
34
+ * ⚠️ DEVELOPMENT ONLY: This is a mock allowlist for development/testing purposes.
35
+ *
36
+ * In production, this MUST be replaced with:
37
+ * - A secure database lookup (e.g., PostgreSQL, DynamoDB)
38
+ * - A secrets management system (e.g., AWS Secrets Manager, HashiCorp Vault)
39
+ * - A Redis cache with appropriate TTL for high-throughput scenarios
40
+ * - Rate limiting per client key to prevent abuse
41
+ *
42
+ * Consider implementing:
43
+ * - Key rotation policies
44
+ * - Per-key rate limiting (especially important for public booking flows)
45
+ * - Audit logging of key usage
46
+ * - Revocation mechanisms
47
+ *
48
+ * TODO: Replace with actual database or cache lookup
49
+ */
50
+ const allowedKeys: Record<
51
+ string,
52
+ { tenantId: string; permissions: string[] }
53
+ > = {
54
+ 'test-key-123': {
55
+ tenantId: 'bright-accountants',
56
+ permissions: ['booking:create'],
57
+ },
58
+ 'admin-ui': {
59
+ tenantId: 'slotly-core',
60
+ permissions: ['*'],
61
+ },
62
+ };
63
+
64
+ /**
65
+ * Validates an API key against the allowlist
66
+ * Returns the associated tenant and permissions if valid
67
+ */
68
+ function validateApiKey(clientKey: string): {
69
+ tenantId: string;
70
+ permissions: string[];
71
+ } | null {
72
+ if (!clientKey || typeof clientKey !== 'string') {
73
+ return null;
74
+ }
75
+
76
+ const keyInfo = allowedKeys[clientKey];
77
+ if (!keyInfo) {
78
+ return null;
79
+ }
80
+
81
+ return {
82
+ tenantId: keyInfo.tenantId,
83
+ permissions: keyInfo.permissions,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Extracts and decodes JWT token from Authorization header
89
+ *
90
+ * If present, extracts user identity from the token. This is optional:
91
+ * - Anonymous/client-only flows: Authorization header not required
92
+ * - User-authenticated flows: Authorization header provides user traceability
93
+ *
94
+ * Returns userId from 'sub' claim and full token claims for additional context.
95
+ * Note: Currently decodes without signature validation (development).
96
+ * TODO: Add signature validation in production using Clerk or JWT library.
97
+ */
98
+ function extractUserToken(authHeader?: string): {
99
+ userId?: string;
100
+ tokenClaims?: Record<string, any>;
101
+ } | null {
102
+ if (!authHeader || typeof authHeader !== 'string') {
103
+ return null;
104
+ }
105
+
106
+ // Extract Bearer token
107
+ const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
108
+ if (!bearerMatch || !bearerMatch[1]) {
109
+ return null;
110
+ }
111
+
112
+ const token = bearerMatch[1];
113
+
114
+ try {
115
+ // Decode JWT without signature validation
116
+ // WARNING: This is for development only. Production should validate signatures.
117
+ // TODO: Add signature validation in production (e.g., using Clerk's verifyToken or jsonwebtoken)
118
+ const claims = jwtDecode(token) as Record<string, any>;
119
+
120
+ if (!claims || typeof claims !== 'object') {
121
+ // eslint-disable-next-line no-console
122
+ console.error('[Slotly SDK] Invalid JWT token: claims is not an object');
123
+ return null;
124
+ }
125
+
126
+ // Extract userId from 'sub' claim
127
+ const userId = claims.sub || claims.user_id || claims.userId;
128
+
129
+ return {
130
+ userId: typeof userId === 'string' ? userId : undefined,
131
+ tokenClaims: claims,
132
+ };
133
+ } catch (error) {
134
+ // eslint-disable-next-line no-console
135
+ console.error('[Slotly SDK] Failed to decode JWT token:', error);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Validates Slotly request headers and extracts authentication context
142
+ *
143
+ * Validates:
144
+ * - `x-slotly-api-key`: Required header identifying the client/application
145
+ * - `Authorization: Bearer ...`: Optional header providing user identity (if present, extracts user identity)
146
+ *
147
+ * Throws SlotlyAuthError if validation fails. Error includes code, message, and details
148
+ * fields that align with the ApiResponse<T> error shape for consistent error handling.
149
+ *
150
+ * @throws {SlotlyAuthError} If x-slotly-api-key is missing, invalid, or not in allowlist
151
+ */
152
+ function validateAndExtractContext(req: NextApiRequest): SlotlyAuthContext {
153
+ // Extract x-slotly-api-key header (required)
154
+ // Support both lowercase and mixed case headers
155
+ const clientKey =
156
+ (req.headers['x-slotly-api-key'] as string) ||
157
+ (req.headers['X-Slotly-Api-Key'] as string) ||
158
+ (req.headers['x-slotly-api-key'] as string);
159
+
160
+ if (!clientKey || typeof clientKey !== 'string' || clientKey.trim().length === 0) {
161
+ const error = new SlotlyAuthError(
162
+ 'Missing or invalid x-slotly-api-key header. API key is required.',
163
+ { header: 'x-slotly-api-key' }
164
+ );
165
+ // eslint-disable-next-line no-console
166
+ console.error('[Slotly SDK] Validation failed:', error.message);
167
+ throw error;
168
+ }
169
+
170
+ // Validate API key against allowlist
171
+ const keyInfo = validateApiKey(clientKey);
172
+ if (!keyInfo) {
173
+ const error = new SlotlyAuthError(
174
+ 'Invalid API key. Client key not found in allowlist.',
175
+ { clientKey: clientKey.substring(0, 8) + '...' }
176
+ );
177
+ // eslint-disable-next-line no-console
178
+ console.error('[Slotly SDK] Validation failed:', error.message);
179
+ throw error;
180
+ }
181
+
182
+ // Extract Authorization header (optional)
183
+ // Support both lowercase and mixed case headers
184
+ const authHeader =
185
+ (req.headers.authorization as string) ||
186
+ (req.headers.Authorization as string);
187
+
188
+ const tokenInfo = authHeader ? extractUserToken(authHeader) : null;
189
+
190
+ // Build context
191
+ const context: SlotlyAuthContext = {
192
+ clientKey,
193
+ tenantId: keyInfo.tenantId,
194
+ permissions: keyInfo.permissions,
195
+ ...(tokenInfo?.userId && { userId: tokenInfo.userId }),
196
+ ...(tokenInfo?.tokenClaims && { tokenClaims: tokenInfo.tokenClaims }),
197
+ };
198
+
199
+ return context;
200
+ }
201
+
202
+ /**
203
+ * Safely extracts Slotly context from Next.js request
204
+ *
205
+ * Use this helper when you need to access the context outside of the middleware wrapper,
206
+ * or to check if the context exists.
207
+ *
208
+ * **Important:** Other route handlers should guard user-required flows:
209
+ * ```ts
210
+ * const context = getSlotlyContext(req);
211
+ * if (!context?.userId) {
212
+ * return res.status(403).json({
213
+ * success: false,
214
+ * error: { code: 'USER_REQUIRED', message: 'User authentication required' }
215
+ * });
216
+ * }
217
+ * ```
218
+ *
219
+ * @param req Next.js API request
220
+ * @returns SlotlyAuthContext if present, null otherwise
221
+ */
222
+ export function getSlotlyContext(
223
+ req: NextApiRequest
224
+ ): SlotlyAuthContext | null {
225
+ const slotlyReq = req as SlotlyRequest;
226
+ return slotlyReq.slotlyContext || null;
227
+ }
228
+
229
+ /**
230
+ * Middleware wrapper for Next.js API routes and Edge Functions
231
+ * Validates Slotly authentication headers and injects context into request
232
+ *
233
+ * **Supported Environments:**
234
+ * - Next.js API Routes (serverless functions)
235
+ * - Next.js Edge Functions (Edge Runtime)
236
+ * - Vercel Edge Functions
237
+ *
238
+ * **Note on Edge Functions:**
239
+ * Header names are normalized, but edge runtime may have different header casing.
240
+ * This middleware handles both `x-slotly-api-key` and `X-Slotly-Api-Key` variants.
241
+ *
242
+ * **Error Response Format:**
243
+ * All error responses follow the `ApiResponse<T>` format for consistency:
244
+ * ```ts
245
+ * {
246
+ * success: false,
247
+ * error: {
248
+ * code: string, // Error code (e.g., 'AUTH_ERROR', 'INTERNAL_ERROR')
249
+ * message: string, // Human-readable error message
250
+ * details?: any // Additional error details
251
+ * }
252
+ * }
253
+ * ```
254
+ *
255
+ * **Usage:**
256
+ * ```ts
257
+ * import { validateSlotlyRequest } from "@slotly/sdk/middleware/validateSlotlyRequest";
258
+ *
259
+ * export default validateSlotlyRequest(async (req, res) => {
260
+ * const { userId, clientKey, tenantId, permissions } = req.slotlyContext;
261
+ *
262
+ * // Check if user authentication is required for this endpoint
263
+ * if (!userId) {
264
+ * return res.status(403).json({
265
+ * success: false,
266
+ * error: { code: 'USER_REQUIRED', message: 'User authentication required' }
267
+ * });
268
+ * }
269
+ *
270
+ * // Authenticated + scoped — safe to proceed
271
+ * res.json({ success: true, data: { userId, tenantId } });
272
+ * });
273
+ * ```
274
+ *
275
+ * **Performance Considerations:**
276
+ * - For high-throughput scenarios (e.g., many public booking flows), consider:
277
+ * - Caching API key lookups with TTL (Redis, in-memory cache)
278
+ * - Rate limiting per client key
279
+ * - Database connection pooling for key validation
280
+ *
281
+ * @param handler Next.js API route handler or Edge Function handler
282
+ * @returns Wrapped handler with Slotly validation
283
+ */
284
+ export function validateSlotlyRequest(
285
+ handler: (req: SlotlyRequest, res: NextApiResponse) => Promise<void> | void
286
+ ) {
287
+ return async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
288
+ try {
289
+ // Validate and extract context
290
+ const context = validateAndExtractContext(req);
291
+
292
+ // Inject context into request
293
+ const slotlyReq = req as SlotlyRequest;
294
+ slotlyReq.slotlyContext = context;
295
+
296
+ // Call the actual handler with validated request
297
+ await handler(slotlyReq, res);
298
+ } catch (error) {
299
+ // Handle SlotlyAuthError
300
+ if (error instanceof SlotlyAuthError) {
301
+ // eslint-disable-next-line no-console
302
+ console.error(
303
+ '[Slotly SDK] Authentication failed:',
304
+ error.message,
305
+ error.details
306
+ );
307
+
308
+ // Error response follows ApiResponse<T> format for consistency
309
+ // SlotlyAuthError includes code, message, and details fields that align
310
+ // with the ApiResponse<T> error shape
311
+ res.status(error.statusCode || 401).json({
312
+ success: false,
313
+ error: {
314
+ code: error.code,
315
+ message: error.message,
316
+ details: error.details,
317
+ },
318
+ });
319
+ return;
320
+ }
321
+
322
+ // Handle other errors
323
+ // eslint-disable-next-line no-console
324
+ console.error('[Slotly SDK] Unexpected error:', error);
325
+
326
+ // Error response follows ApiResponse<T> format for consistency
327
+ res.status(500).json({
328
+ success: false,
329
+ error: {
330
+ code: 'INTERNAL_ERROR',
331
+ message: 'An unexpected error occurred',
332
+ },
333
+ });
334
+ }
335
+ };
336
+ }
337
+
@@ -0,0 +1,32 @@
1
+ import axiosMockAdapter from 'axios-mock-adapter';
2
+ import { AxiosInstance } from 'axios';
3
+
4
+ /**
5
+ * Setup mock adapter for testing
6
+ * This allows you to mock API responses during development and testing
7
+ *
8
+ * Usage:
9
+ * ```ts
10
+ * import { createSlotlyClient } from '../client';
11
+ * import { setupMockAdapter } from './mocks/mockAdapter';
12
+ *
13
+ * const client = createSlotlyClient();
14
+ * const mockAdapter = setupMockAdapter(client);
15
+ *
16
+ * // Mock a response
17
+ * mockAdapter.onGet('/api/v1/tenant-config/my-slug').reply(200, {
18
+ * success: true,
19
+ * data: { id: '1', slug: 'my-slug', name: 'My Tenant' }
20
+ * });
21
+ * ```
22
+ */
23
+ export function setupMockAdapter(client: AxiosInstance): axiosMockAdapter {
24
+ const mockAdapter = new axiosMockAdapter(client, {
25
+ delayResponse: 0, // Set to simulate network delay if needed
26
+ });
27
+
28
+ // TODO: Add default mock responses for common endpoints if needed
29
+
30
+ return mockAdapter;
31
+ }
32
+
package/src/retry.ts ADDED
@@ -0,0 +1,82 @@
1
+ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
2
+
3
+ /**
4
+ * Exponential backoff retry configuration
5
+ */
6
+ export interface RetryConfig {
7
+ maxRetries: number;
8
+ retryDelay: number; // Base delay in milliseconds
9
+ retryCondition?: (error: AxiosError) => boolean;
10
+ }
11
+
12
+ const defaultRetryConfig: RetryConfig = {
13
+ maxRetries: 3,
14
+ retryDelay: 1000, // 1 second
15
+ retryCondition: (error: AxiosError) => {
16
+ // Retry on network errors and 5xx server errors
17
+ if (!error.response) {
18
+ return true; // Network error
19
+ }
20
+ const status = error.response.status;
21
+ return status >= 500 && status < 600;
22
+ },
23
+ };
24
+
25
+ /**
26
+ * Calculate exponential backoff delay with jitter
27
+ */
28
+ function calculateRetryDelay(attempt: number, baseDelay: number): number {
29
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
30
+ const jitter = Math.random() * 0.3 * exponentialDelay; // Add up to 30% jitter
31
+ return exponentialDelay + jitter;
32
+ }
33
+
34
+ /**
35
+ * Axios interceptor for retry logic
36
+ */
37
+ export function setupRetryInterceptor(
38
+ config: RetryConfig = defaultRetryConfig
39
+ ) {
40
+ return async (error: AxiosError) => {
41
+ const requestConfig = error.config as InternalAxiosRequestConfig & {
42
+ _retry?: boolean;
43
+ _retryCount?: number;
44
+ };
45
+
46
+ // If no config, reject
47
+ if (!requestConfig) {
48
+ return Promise.reject(error);
49
+ }
50
+
51
+ // Initialize retry count
52
+ if (requestConfig._retryCount === undefined) {
53
+ requestConfig._retryCount = 0;
54
+ }
55
+
56
+ // Check if we should retry
57
+ const shouldRetry =
58
+ requestConfig._retryCount < config.maxRetries &&
59
+ (!config.retryCondition || config.retryCondition(error));
60
+
61
+ if (!shouldRetry) {
62
+ return Promise.reject(error);
63
+ }
64
+
65
+ // Mark as retrying
66
+ requestConfig._retry = true;
67
+ requestConfig._retryCount += 1;
68
+
69
+ // Calculate delay
70
+ const delay = calculateRetryDelay(
71
+ requestConfig._retryCount - 1,
72
+ config.retryDelay
73
+ );
74
+
75
+ // Wait before retrying
76
+ await new Promise((resolve) => setTimeout(resolve, delay));
77
+
78
+ // Retry the request
79
+ return axios(requestConfig);
80
+ };
81
+ }
82
+
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Standard API response format for all Slotly API endpoints
3
+ */
4
+ export interface ApiResponse<T> {
5
+ success: boolean;
6
+ data?: T;
7
+ error?: {
8
+ code: string;
9
+ message: string;
10
+ details?: any;
11
+ };
12
+ pagination?: {
13
+ page: number;
14
+ limit: number;
15
+ total: number;
16
+ totalPages: number;
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Placeholder types for domain models
22
+ * TODO: Replace with actual types from the Slotly API
23
+ */
24
+
25
+ export interface Tenant {
26
+ id: string;
27
+ slug: string;
28
+ name: string;
29
+ // TODO: Add actual tenant properties
30
+ }
31
+
32
+ /**
33
+ * Tenant branding configuration
34
+ */
35
+ export interface TenantBranding {
36
+ logoUrl?: string;
37
+ primaryColor?: string;
38
+ secondaryColor?: string;
39
+ faviconUrl?: string;
40
+ customCss?: string;
41
+ // TODO: Add additional branding properties
42
+ }
43
+
44
+ /**
45
+ * Service item within a service
46
+ */
47
+ export interface ServiceItem {
48
+ id: string;
49
+ serviceId: string;
50
+ name: string;
51
+ description?: string;
52
+ price?: number;
53
+ duration?: number;
54
+ available?: boolean;
55
+ // TODO: Add actual service item properties
56
+ }
57
+
58
+ /**
59
+ * Enhanced Service type with nested service items
60
+ */
61
+ export interface ServiceWithItems extends Service {
62
+ /** Array of service items nested within this service (optional, may be empty array) */
63
+ serviceItems?: ServiceItem[];
64
+ }
65
+
66
+ /**
67
+ * Service item with parent service reference
68
+ */
69
+ export interface ServiceItemWithService extends ServiceItem {
70
+ /** Parent service reference (optional, for convenience when fetching items separately) */
71
+ parentService?: Service;
72
+ }
73
+
74
+ /**
75
+ * Category extracted from services
76
+ */
77
+ export interface Category {
78
+ id: string;
79
+ name: string;
80
+ slug?: string;
81
+ description?: string;
82
+ /** Array of service IDs in this category */
83
+ serviceIds?: string[];
84
+ /** Optional: services in this category (if expanded) */
85
+ services?: Service[];
86
+ // TODO: Add actual category properties
87
+ }
88
+
89
+ export interface Booking {
90
+ id: string;
91
+ tenantId: string;
92
+ slotId?: string;
93
+ customerId?: string;
94
+ status?: string;
95
+ // TODO: Add actual booking properties
96
+ }
97
+
98
+ export interface Service {
99
+ id: string;
100
+ tenantId: string;
101
+ name: string;
102
+ description?: string;
103
+ duration?: number;
104
+ price?: number;
105
+ // TODO: Add actual service properties
106
+ }
107
+
108
+ export interface Slot {
109
+ id: string;
110
+ tenantId: string;
111
+ serviceId?: string;
112
+ startTime: string;
113
+ endTime: string;
114
+ available: boolean;
115
+ // TODO: Add actual slot properties
116
+ }
117
+
118
+ export interface Customer {
119
+ id: string;
120
+ tenantId: string;
121
+ email?: string;
122
+ name?: string;
123
+ phone?: string;
124
+ // TODO: Add actual customer properties
125
+ }
126
+
127
+ export interface Flow {
128
+ id: string;
129
+ tenantId: string;
130
+ name: string;
131
+ steps?: FlowStep[];
132
+ // TODO: Add actual flow properties
133
+ }
134
+
135
+ export interface FlowStep {
136
+ id: string;
137
+ type: string;
138
+ config?: Record<string, any>;
139
+ // TODO: Add actual flow step properties
140
+ }
141
+
142
+ export interface Studio {
143
+ id: string;
144
+ name: string;
145
+ slug?: string;
146
+ // TODO: Add actual studio properties
147
+ }
148
+
149
+ export interface Notification {
150
+ id: string;
151
+ tenantId?: string;
152
+ type: string;
153
+ recipient: string;
154
+ content: string;
155
+ sentAt?: string;
156
+ // TODO: Add actual notification properties
157
+ }
158
+
159
+ export interface DataQualityIssue {
160
+ id: string;
161
+ tenantId?: string;
162
+ type: string;
163
+ severity: 'low' | 'medium' | 'high';
164
+ message: string;
165
+ resolved: boolean;
166
+ // TODO: Add actual data quality issue properties
167
+ }
168
+
169
+ /**
170
+ * Full tenant configuration including branding, services, and service items
171
+ * Service items are nested within each service object (not as a separate top-level array)
172
+ */
173
+ export interface TenantFullConfig {
174
+ /** Tenant ID (required, non-nullable) */
175
+ id: string;
176
+ /** Tenant slug (required, non-nullable) */
177
+ slug: string;
178
+ /** Tenant name (required, non-nullable) */
179
+ name: string;
180
+ /** Branding configuration (optional) */
181
+ branding?: TenantBranding;
182
+ /** Array of services with nested service items (required, non-nullable, may be empty array) */
183
+ services: ServiceWithItems[];
184
+ /** Enabled features (optional) */
185
+ featuresEnabled?: string[];
186
+ /** Subscription level (optional) */
187
+ subscriptionLevel?: string;
188
+ /** Additional configuration (optional) */
189
+ config?: Record<string, any>;
190
+ // TODO: Add additional tenant config properties
191
+ }
192
+