lsh-framework 3.1.5 → 3.1.7
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/dist/__tests__/fixtures/job-fixtures.js +204 -0
- package/dist/__tests__/fixtures/supabase-mocks.js +252 -0
- package/dist/cli.js +12 -2
- package/dist/lib/database-types.js +90 -0
- package/dist/lib/lsh-error.js +406 -0
- package/dist/lib/saas-auth.js +39 -2
- package/dist/lib/saas-billing.js +106 -8
- package/dist/lib/saas-organizations.js +74 -5
- package/dist/lib/saas-secrets.js +30 -2
- package/package.json +3 -3
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH Error Handling Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides standardized error handling across the LSH codebase:
|
|
5
|
+
* - LSHError class for structured errors with codes and context
|
|
6
|
+
* - Error extraction utilities for safe error handling in catch blocks
|
|
7
|
+
* - Error code constants for consistent error identification
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { LSHError, ErrorCodes, extractErrorMessage } from './lsh-error.js';
|
|
12
|
+
*
|
|
13
|
+
* // Throw a structured error
|
|
14
|
+
* throw new LSHError(
|
|
15
|
+
* ErrorCodes.SECRETS_ENCRYPTION_FAILED,
|
|
16
|
+
* 'Failed to encrypt secret',
|
|
17
|
+
* { secretKey: 'DATABASE_URL', teamId: 'team_123' }
|
|
18
|
+
* );
|
|
19
|
+
*
|
|
20
|
+
* // Safe error extraction in catch block
|
|
21
|
+
* try {
|
|
22
|
+
* await doSomething();
|
|
23
|
+
* } catch (error) {
|
|
24
|
+
* console.error(extractErrorMessage(error));
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// ERROR CODES
|
|
30
|
+
// ============================================================================
|
|
31
|
+
/**
|
|
32
|
+
* Standardized error codes for LSH operations.
|
|
33
|
+
* Use these instead of magic strings for consistent error handling.
|
|
34
|
+
*
|
|
35
|
+
* Naming convention: CATEGORY_SPECIFIC_ERROR
|
|
36
|
+
* - AUTH_* : Authentication errors
|
|
37
|
+
* - SECRETS_* : Secrets management errors
|
|
38
|
+
* - DB_* : Database errors
|
|
39
|
+
* - DAEMON_* : Daemon/job errors
|
|
40
|
+
* - API_* : API server errors
|
|
41
|
+
* - CONFIG_* : Configuration errors
|
|
42
|
+
* - VALIDATION_* : Input validation errors
|
|
43
|
+
*/
|
|
44
|
+
export const ErrorCodes = {
|
|
45
|
+
// Authentication errors
|
|
46
|
+
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
|
|
47
|
+
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
|
|
48
|
+
AUTH_EMAIL_NOT_VERIFIED: 'AUTH_EMAIL_NOT_VERIFIED',
|
|
49
|
+
AUTH_EMAIL_ALREADY_EXISTS: 'AUTH_EMAIL_ALREADY_EXISTS',
|
|
50
|
+
AUTH_INVALID_TOKEN: 'AUTH_INVALID_TOKEN',
|
|
51
|
+
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
|
|
52
|
+
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
|
|
53
|
+
AUTH_INSUFFICIENT_PERMISSIONS: 'AUTH_INSUFFICIENT_PERMISSIONS',
|
|
54
|
+
// Secrets management errors
|
|
55
|
+
SECRETS_ENCRYPTION_FAILED: 'SECRETS_ENCRYPTION_FAILED',
|
|
56
|
+
SECRETS_DECRYPTION_FAILED: 'SECRETS_DECRYPTION_FAILED',
|
|
57
|
+
SECRETS_KEY_NOT_FOUND: 'SECRETS_KEY_NOT_FOUND',
|
|
58
|
+
SECRETS_PUSH_FAILED: 'SECRETS_PUSH_FAILED',
|
|
59
|
+
SECRETS_PULL_FAILED: 'SECRETS_PULL_FAILED',
|
|
60
|
+
SECRETS_ROTATION_FAILED: 'SECRETS_ROTATION_FAILED',
|
|
61
|
+
SECRETS_ENV_PARSE_FAILED: 'SECRETS_ENV_PARSE_FAILED',
|
|
62
|
+
SECRETS_NOT_FOUND: 'SECRETS_NOT_FOUND',
|
|
63
|
+
// Database errors
|
|
64
|
+
DB_CONNECTION_FAILED: 'DB_CONNECTION_FAILED',
|
|
65
|
+
DB_QUERY_FAILED: 'DB_QUERY_FAILED',
|
|
66
|
+
DB_NOT_FOUND: 'DB_NOT_FOUND',
|
|
67
|
+
DB_ALREADY_EXISTS: 'DB_ALREADY_EXISTS',
|
|
68
|
+
DB_CONSTRAINT_VIOLATION: 'DB_CONSTRAINT_VIOLATION',
|
|
69
|
+
DB_TIMEOUT: 'DB_TIMEOUT',
|
|
70
|
+
// Daemon errors
|
|
71
|
+
DAEMON_NOT_RUNNING: 'DAEMON_NOT_RUNNING',
|
|
72
|
+
DAEMON_ALREADY_RUNNING: 'DAEMON_ALREADY_RUNNING',
|
|
73
|
+
DAEMON_START_FAILED: 'DAEMON_START_FAILED',
|
|
74
|
+
DAEMON_STOP_FAILED: 'DAEMON_STOP_FAILED',
|
|
75
|
+
DAEMON_CONNECTION_FAILED: 'DAEMON_CONNECTION_FAILED',
|
|
76
|
+
DAEMON_IPC_ERROR: 'DAEMON_IPC_ERROR',
|
|
77
|
+
// Job errors
|
|
78
|
+
JOB_NOT_FOUND: 'JOB_NOT_FOUND',
|
|
79
|
+
JOB_ALREADY_RUNNING: 'JOB_ALREADY_RUNNING',
|
|
80
|
+
JOB_START_FAILED: 'JOB_START_FAILED',
|
|
81
|
+
JOB_STOP_FAILED: 'JOB_STOP_FAILED',
|
|
82
|
+
JOB_TIMEOUT: 'JOB_TIMEOUT',
|
|
83
|
+
JOB_INVALID_SCHEDULE: 'JOB_INVALID_SCHEDULE',
|
|
84
|
+
// API errors
|
|
85
|
+
API_NOT_CONFIGURED: 'API_NOT_CONFIGURED',
|
|
86
|
+
API_INVALID_REQUEST: 'API_INVALID_REQUEST',
|
|
87
|
+
API_RATE_LIMITED: 'API_RATE_LIMITED',
|
|
88
|
+
API_INTERNAL_ERROR: 'API_INTERNAL_ERROR',
|
|
89
|
+
API_WEBHOOK_VERIFICATION_FAILED: 'API_WEBHOOK_VERIFICATION_FAILED',
|
|
90
|
+
// Configuration errors
|
|
91
|
+
CONFIG_MISSING_ENV_VAR: 'CONFIG_MISSING_ENV_VAR',
|
|
92
|
+
CONFIG_INVALID_VALUE: 'CONFIG_INVALID_VALUE',
|
|
93
|
+
CONFIG_FILE_NOT_FOUND: 'CONFIG_FILE_NOT_FOUND',
|
|
94
|
+
CONFIG_PARSE_ERROR: 'CONFIG_PARSE_ERROR',
|
|
95
|
+
// Validation errors
|
|
96
|
+
VALIDATION_REQUIRED_FIELD: 'VALIDATION_REQUIRED_FIELD',
|
|
97
|
+
VALIDATION_INVALID_FORMAT: 'VALIDATION_INVALID_FORMAT',
|
|
98
|
+
VALIDATION_OUT_OF_RANGE: 'VALIDATION_OUT_OF_RANGE',
|
|
99
|
+
VALIDATION_COMMAND_INJECTION: 'VALIDATION_COMMAND_INJECTION',
|
|
100
|
+
// Billing errors
|
|
101
|
+
BILLING_TIER_LIMIT_EXCEEDED: 'BILLING_TIER_LIMIT_EXCEEDED',
|
|
102
|
+
BILLING_SUBSCRIPTION_REQUIRED: 'BILLING_SUBSCRIPTION_REQUIRED',
|
|
103
|
+
BILLING_PAYMENT_REQUIRED: 'BILLING_PAYMENT_REQUIRED',
|
|
104
|
+
BILLING_STRIPE_ERROR: 'BILLING_STRIPE_ERROR',
|
|
105
|
+
// Resource errors
|
|
106
|
+
RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
|
|
107
|
+
RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',
|
|
108
|
+
RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',
|
|
109
|
+
// General errors
|
|
110
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
111
|
+
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
|
112
|
+
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
|
113
|
+
};
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// LSH ERROR CLASS
|
|
116
|
+
// ============================================================================
|
|
117
|
+
/**
|
|
118
|
+
* Structured error class for LSH operations.
|
|
119
|
+
*
|
|
120
|
+
* Features:
|
|
121
|
+
* - Error code for programmatic handling
|
|
122
|
+
* - Context object for debugging information
|
|
123
|
+
* - HTTP status code mapping for API responses
|
|
124
|
+
* - Stack trace preservation
|
|
125
|
+
* - Serialization support
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* throw new LSHError(
|
|
130
|
+
* ErrorCodes.SECRETS_NOT_FOUND,
|
|
131
|
+
* 'Secret not found',
|
|
132
|
+
* { secretId: 'secret_123', environment: 'production' }
|
|
133
|
+
* );
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export class LSHError extends Error {
|
|
137
|
+
/** Error code for programmatic handling */
|
|
138
|
+
code;
|
|
139
|
+
/** Additional context for debugging */
|
|
140
|
+
context;
|
|
141
|
+
/** HTTP status code for API responses */
|
|
142
|
+
statusCode;
|
|
143
|
+
/** Timestamp when error occurred */
|
|
144
|
+
timestamp;
|
|
145
|
+
constructor(code, message, context, statusCode) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = 'LSHError';
|
|
148
|
+
this.code = code;
|
|
149
|
+
this.context = context;
|
|
150
|
+
this.statusCode = statusCode ?? getDefaultStatusCode(code);
|
|
151
|
+
this.timestamp = new Date();
|
|
152
|
+
// Maintain proper stack trace in V8 environments
|
|
153
|
+
if (Error.captureStackTrace) {
|
|
154
|
+
Error.captureStackTrace(this, LSHError);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Serialize error for logging or API response.
|
|
159
|
+
*/
|
|
160
|
+
toJSON() {
|
|
161
|
+
return {
|
|
162
|
+
name: this.name,
|
|
163
|
+
code: this.code,
|
|
164
|
+
message: this.message,
|
|
165
|
+
context: this.context,
|
|
166
|
+
statusCode: this.statusCode,
|
|
167
|
+
timestamp: this.timestamp.toISOString(),
|
|
168
|
+
stack: this.stack,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create user-friendly string representation.
|
|
173
|
+
*/
|
|
174
|
+
toString() {
|
|
175
|
+
const contextStr = this.context ? ` (${JSON.stringify(this.context)})` : '';
|
|
176
|
+
return `[${this.code}] ${this.message}${contextStr}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// ERROR UTILITIES
|
|
181
|
+
// ============================================================================
|
|
182
|
+
/**
|
|
183
|
+
* Safely extract error message from unknown error type.
|
|
184
|
+
* Use this in catch blocks instead of (error as Error).message.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```typescript
|
|
188
|
+
* try {
|
|
189
|
+
* await riskyOperation();
|
|
190
|
+
* } catch (error) {
|
|
191
|
+
* console.error('Failed:', extractErrorMessage(error));
|
|
192
|
+
* }
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
export function extractErrorMessage(error) {
|
|
196
|
+
if (error instanceof LSHError) {
|
|
197
|
+
return error.toString();
|
|
198
|
+
}
|
|
199
|
+
if (error instanceof Error) {
|
|
200
|
+
return error.message;
|
|
201
|
+
}
|
|
202
|
+
if (typeof error === 'string') {
|
|
203
|
+
return error;
|
|
204
|
+
}
|
|
205
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
206
|
+
return String(error.message);
|
|
207
|
+
}
|
|
208
|
+
return String(error);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Safely extract error details for logging.
|
|
212
|
+
* Returns structured object with message, stack, and code if available.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```typescript
|
|
216
|
+
* try {
|
|
217
|
+
* await riskyOperation();
|
|
218
|
+
* } catch (error) {
|
|
219
|
+
* logger.error('Operation failed', extractErrorDetails(error));
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
export function extractErrorDetails(error) {
|
|
224
|
+
if (error instanceof LSHError) {
|
|
225
|
+
return {
|
|
226
|
+
message: error.message,
|
|
227
|
+
stack: error.stack,
|
|
228
|
+
code: error.code,
|
|
229
|
+
context: error.context,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (error instanceof Error) {
|
|
233
|
+
return {
|
|
234
|
+
message: error.message,
|
|
235
|
+
stack: error.stack,
|
|
236
|
+
code: error.code,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
message: extractErrorMessage(error),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Check if an error is an LSHError with a specific code.
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```typescript
|
|
248
|
+
* try {
|
|
249
|
+
* await getSecret();
|
|
250
|
+
* } catch (error) {
|
|
251
|
+
* if (isLSHError(error, ErrorCodes.SECRETS_NOT_FOUND)) {
|
|
252
|
+
* // Handle not found case
|
|
253
|
+
* }
|
|
254
|
+
* throw error;
|
|
255
|
+
* }
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export function isLSHError(error, code) {
|
|
259
|
+
if (!(error instanceof LSHError))
|
|
260
|
+
return false;
|
|
261
|
+
if (code && error.code !== code)
|
|
262
|
+
return false;
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Wrap an unknown error as an LSHError.
|
|
267
|
+
* Useful for ensuring consistent error types in APIs.
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```typescript
|
|
271
|
+
* try {
|
|
272
|
+
* await externalApi.call();
|
|
273
|
+
* } catch (error) {
|
|
274
|
+
* throw wrapAsLSHError(error, ErrorCodes.API_INTERNAL_ERROR);
|
|
275
|
+
* }
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
export function wrapAsLSHError(error, code = ErrorCodes.INTERNAL_ERROR, context) {
|
|
279
|
+
if (error instanceof LSHError) {
|
|
280
|
+
// Add additional context if provided
|
|
281
|
+
if (context) {
|
|
282
|
+
return new LSHError(error.code, error.message, {
|
|
283
|
+
...error.context,
|
|
284
|
+
...context,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return error;
|
|
288
|
+
}
|
|
289
|
+
const message = extractErrorMessage(error);
|
|
290
|
+
const details = extractErrorDetails(error);
|
|
291
|
+
return new LSHError(code, message, {
|
|
292
|
+
...context,
|
|
293
|
+
originalStack: details.stack,
|
|
294
|
+
originalCode: details.code,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// HTTP STATUS CODE MAPPING
|
|
299
|
+
// ============================================================================
|
|
300
|
+
/**
|
|
301
|
+
* Get default HTTP status code for an error code.
|
|
302
|
+
* Used by LSHError constructor and API responses.
|
|
303
|
+
*/
|
|
304
|
+
function getDefaultStatusCode(code) {
|
|
305
|
+
// 400 Bad Request
|
|
306
|
+
if (code.startsWith('VALIDATION_'))
|
|
307
|
+
return 400;
|
|
308
|
+
if (code === ErrorCodes.API_INVALID_REQUEST)
|
|
309
|
+
return 400;
|
|
310
|
+
if (code === ErrorCodes.CONFIG_INVALID_VALUE)
|
|
311
|
+
return 400;
|
|
312
|
+
// 401 Unauthorized
|
|
313
|
+
if (code === ErrorCodes.AUTH_UNAUTHORIZED)
|
|
314
|
+
return 401;
|
|
315
|
+
if (code === ErrorCodes.AUTH_INVALID_CREDENTIALS)
|
|
316
|
+
return 401;
|
|
317
|
+
if (code === ErrorCodes.AUTH_INVALID_TOKEN)
|
|
318
|
+
return 401;
|
|
319
|
+
if (code === ErrorCodes.AUTH_TOKEN_EXPIRED)
|
|
320
|
+
return 401;
|
|
321
|
+
// 402 Payment Required
|
|
322
|
+
if (code === ErrorCodes.BILLING_PAYMENT_REQUIRED)
|
|
323
|
+
return 402;
|
|
324
|
+
if (code === ErrorCodes.BILLING_SUBSCRIPTION_REQUIRED)
|
|
325
|
+
return 402;
|
|
326
|
+
// 403 Forbidden
|
|
327
|
+
if (code === ErrorCodes.AUTH_FORBIDDEN)
|
|
328
|
+
return 403;
|
|
329
|
+
if (code === ErrorCodes.AUTH_INSUFFICIENT_PERMISSIONS)
|
|
330
|
+
return 403;
|
|
331
|
+
if (code === ErrorCodes.AUTH_EMAIL_NOT_VERIFIED)
|
|
332
|
+
return 403;
|
|
333
|
+
// 404 Not Found
|
|
334
|
+
if (code.endsWith('_NOT_FOUND'))
|
|
335
|
+
return 404;
|
|
336
|
+
if (code === ErrorCodes.DB_NOT_FOUND)
|
|
337
|
+
return 404;
|
|
338
|
+
// 409 Conflict
|
|
339
|
+
if (code.endsWith('_ALREADY_EXISTS'))
|
|
340
|
+
return 409;
|
|
341
|
+
if (code === ErrorCodes.RESOURCE_CONFLICT)
|
|
342
|
+
return 409;
|
|
343
|
+
if (code === ErrorCodes.DB_CONSTRAINT_VIOLATION)
|
|
344
|
+
return 409;
|
|
345
|
+
// 429 Too Many Requests
|
|
346
|
+
if (code === ErrorCodes.API_RATE_LIMITED)
|
|
347
|
+
return 429;
|
|
348
|
+
if (code === ErrorCodes.BILLING_TIER_LIMIT_EXCEEDED)
|
|
349
|
+
return 429;
|
|
350
|
+
// 501 Not Implemented
|
|
351
|
+
if (code === ErrorCodes.NOT_IMPLEMENTED)
|
|
352
|
+
return 501;
|
|
353
|
+
// 503 Service Unavailable
|
|
354
|
+
if (code === ErrorCodes.SERVICE_UNAVAILABLE)
|
|
355
|
+
return 503;
|
|
356
|
+
if (code === ErrorCodes.DB_CONNECTION_FAILED)
|
|
357
|
+
return 503;
|
|
358
|
+
// 504 Gateway Timeout
|
|
359
|
+
if (code === ErrorCodes.DB_TIMEOUT)
|
|
360
|
+
return 504;
|
|
361
|
+
if (code === ErrorCodes.JOB_TIMEOUT)
|
|
362
|
+
return 504;
|
|
363
|
+
// Default to 500 Internal Server Error
|
|
364
|
+
return 500;
|
|
365
|
+
}
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// FACTORY FUNCTIONS
|
|
368
|
+
// ============================================================================
|
|
369
|
+
/**
|
|
370
|
+
* Create a not found error.
|
|
371
|
+
*/
|
|
372
|
+
export function notFoundError(resource, id, context) {
|
|
373
|
+
const message = id ? `${resource} '${id}' not found` : `${resource} not found`;
|
|
374
|
+
return new LSHError(ErrorCodes.RESOURCE_NOT_FOUND, message, { resource, id, ...context });
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Create an already exists error.
|
|
378
|
+
*/
|
|
379
|
+
export function alreadyExistsError(resource, identifier, context) {
|
|
380
|
+
const message = identifier
|
|
381
|
+
? `${resource} '${identifier}' already exists`
|
|
382
|
+
: `${resource} already exists`;
|
|
383
|
+
return new LSHError(ErrorCodes.RESOURCE_ALREADY_EXISTS, message, {
|
|
384
|
+
resource,
|
|
385
|
+
identifier,
|
|
386
|
+
...context,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Create a validation error.
|
|
391
|
+
*/
|
|
392
|
+
export function validationError(message, field, context) {
|
|
393
|
+
return new LSHError(ErrorCodes.VALIDATION_REQUIRED_FIELD, message, { field, ...context });
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Create an unauthorized error.
|
|
397
|
+
*/
|
|
398
|
+
export function unauthorizedError(message = 'Unauthorized', context) {
|
|
399
|
+
return new LSHError(ErrorCodes.AUTH_UNAUTHORIZED, message, context);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Create a forbidden error.
|
|
403
|
+
*/
|
|
404
|
+
export function forbiddenError(message = 'Insufficient permissions', context) {
|
|
405
|
+
return new LSHError(ErrorCodes.AUTH_FORBIDDEN, message, context);
|
|
406
|
+
}
|
package/dist/lib/saas-auth.js
CHANGED
|
@@ -378,7 +378,29 @@ export class AuthService {
|
|
|
378
378
|
await this.supabase.from('users').update({ password_hash: newHash }).eq('id', userId);
|
|
379
379
|
}
|
|
380
380
|
/**
|
|
381
|
-
*
|
|
381
|
+
* Transform Supabase user record to domain model.
|
|
382
|
+
*
|
|
383
|
+
* Maps database snake_case columns to TypeScript camelCase properties:
|
|
384
|
+
* - `email_verified` → `emailVerified` (boolean)
|
|
385
|
+
* - `email_verification_token` → `emailVerificationToken` (nullable string)
|
|
386
|
+
* - `email_verification_expires_at` → `emailVerificationExpiresAt` (nullable Date)
|
|
387
|
+
* - `password_hash` → `passwordHash` (nullable, null for OAuth-only users)
|
|
388
|
+
* - `oauth_provider` → `oauthProvider` ('google' | 'github' | 'microsoft' | null)
|
|
389
|
+
* - `oauth_provider_id` → `oauthProviderId` (nullable)
|
|
390
|
+
* - `first_name` → `firstName` (nullable)
|
|
391
|
+
* - `last_name` → `lastName` (nullable)
|
|
392
|
+
* - `avatar_url` → `avatarUrl` (nullable)
|
|
393
|
+
* - `last_login_at` → `lastLoginAt` (nullable Date)
|
|
394
|
+
* - `last_login_ip` → `lastLoginIp` (nullable)
|
|
395
|
+
* - `deleted_at` → `deletedAt` (nullable Date, for soft delete)
|
|
396
|
+
*
|
|
397
|
+
* Security note: passwordHash is included in domain model but should
|
|
398
|
+
* never be exposed in API responses.
|
|
399
|
+
*
|
|
400
|
+
* @param dbUser - Supabase record from 'users' table
|
|
401
|
+
* @returns Domain User object
|
|
402
|
+
* @see DbUserRecord in database-types.ts for input shape
|
|
403
|
+
* @see User in saas-types.ts for output shape
|
|
382
404
|
*/
|
|
383
405
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
|
|
384
406
|
mapDbUserToUser(dbUser) {
|
|
@@ -404,7 +426,22 @@ export class AuthService {
|
|
|
404
426
|
};
|
|
405
427
|
}
|
|
406
428
|
/**
|
|
407
|
-
*
|
|
429
|
+
* Transform Supabase organization record to domain model.
|
|
430
|
+
*
|
|
431
|
+
* Used when fetching user's organizations via the organization_members join.
|
|
432
|
+
* Identical mapping logic to OrganizationService.mapDbOrgToOrg().
|
|
433
|
+
*
|
|
434
|
+
* Maps database snake_case columns to TypeScript camelCase properties:
|
|
435
|
+
* - `stripe_customer_id` → `stripeCustomerId`
|
|
436
|
+
* - `subscription_tier` → `subscriptionTier` (SubscriptionTier type)
|
|
437
|
+
* - `subscription_status` → `subscriptionStatus` (SubscriptionStatus type)
|
|
438
|
+
* - `subscription_expires_at` → `subscriptionExpiresAt` (nullable Date)
|
|
439
|
+
* - `deleted_at` → `deletedAt` (nullable Date)
|
|
440
|
+
*
|
|
441
|
+
* @param dbOrg - Supabase record from 'organizations' table (via join)
|
|
442
|
+
* @returns Domain Organization object
|
|
443
|
+
* @see DbOrganizationRecord in database-types.ts for input shape
|
|
444
|
+
* @see Organization in saas-types.ts for output shape
|
|
408
445
|
*/
|
|
409
446
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
|
|
410
447
|
mapDbOrgToOrg(dbOrg) {
|
package/dist/lib/saas-billing.js
CHANGED
|
@@ -154,7 +154,22 @@ export class BillingService {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
/**
|
|
157
|
-
* Verify webhook signature
|
|
157
|
+
* Verify Stripe webhook signature and parse event payload.
|
|
158
|
+
*
|
|
159
|
+
* In production, this should use Stripe's signature verification:
|
|
160
|
+
* ```typescript
|
|
161
|
+
* const event = stripe.webhooks.constructEvent(
|
|
162
|
+
* payload, signature, this.stripeWebhookSecret
|
|
163
|
+
* );
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* Current implementation parses JSON without verification (TODO: implement proper verification).
|
|
167
|
+
*
|
|
168
|
+
* @param payload - Raw webhook body as string
|
|
169
|
+
* @param _signature - Stripe-Signature header value (not yet used)
|
|
170
|
+
* @returns Parsed Stripe event object
|
|
171
|
+
* @throws Error if payload is not valid JSON
|
|
172
|
+
* @see https://stripe.com/docs/webhooks/signatures
|
|
158
173
|
*/
|
|
159
174
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe event structure
|
|
160
175
|
verifyWebhookSignature(payload, _signature) {
|
|
@@ -168,7 +183,16 @@ export class BillingService {
|
|
|
168
183
|
}
|
|
169
184
|
}
|
|
170
185
|
/**
|
|
171
|
-
* Handle checkout
|
|
186
|
+
* Handle Stripe checkout.session.completed webhook event.
|
|
187
|
+
*
|
|
188
|
+
* Called when a customer completes checkout. The actual subscription
|
|
189
|
+
* creation is handled by the customer.subscription.created event.
|
|
190
|
+
*
|
|
191
|
+
* Extracts organization_id from session.metadata to link the checkout
|
|
192
|
+
* to the correct organization.
|
|
193
|
+
*
|
|
194
|
+
* @param session - Stripe checkout session object
|
|
195
|
+
* @see StripeCheckoutSession in database-types.ts for partial type
|
|
172
196
|
*/
|
|
173
197
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe checkout session object
|
|
174
198
|
async handleCheckoutCompleted(session) {
|
|
@@ -181,7 +205,20 @@ export class BillingService {
|
|
|
181
205
|
console.log(`Checkout completed for organization ${organizationId}`);
|
|
182
206
|
}
|
|
183
207
|
/**
|
|
184
|
-
* Handle subscription
|
|
208
|
+
* Handle Stripe customer.subscription.created/updated webhook events.
|
|
209
|
+
*
|
|
210
|
+
* Creates or updates subscription record in database and syncs tier
|
|
211
|
+
* to the organization. Key operations:
|
|
212
|
+
* 1. Extracts organization_id from subscription.metadata
|
|
213
|
+
* 2. Determines tier from price ID (maps Stripe price → 'free' | 'pro' | 'enterprise')
|
|
214
|
+
* 3. Upserts subscription record with all billing details
|
|
215
|
+
* 4. Updates organization's subscription_tier and subscription_status
|
|
216
|
+
* 5. Logs audit event
|
|
217
|
+
*
|
|
218
|
+
* Timestamps from Stripe are Unix timestamps (seconds), converted to ISO strings.
|
|
219
|
+
*
|
|
220
|
+
* @param subscription - Stripe subscription object
|
|
221
|
+
* @see StripeSubscriptionEvent in database-types.ts for partial type
|
|
185
222
|
*/
|
|
186
223
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
|
|
187
224
|
async handleSubscriptionUpdated(subscription) {
|
|
@@ -235,7 +272,17 @@ export class BillingService {
|
|
|
235
272
|
});
|
|
236
273
|
}
|
|
237
274
|
/**
|
|
238
|
-
* Handle subscription
|
|
275
|
+
* Handle Stripe customer.subscription.deleted webhook event.
|
|
276
|
+
*
|
|
277
|
+
* Called when a subscription is canceled (immediate or at period end).
|
|
278
|
+
* Operations:
|
|
279
|
+
* 1. Marks subscription as 'canceled' with canceled_at timestamp
|
|
280
|
+
* 2. Downgrades organization to 'free' tier
|
|
281
|
+
* 3. Updates organization subscription_status to 'canceled'
|
|
282
|
+
* 4. Logs audit event
|
|
283
|
+
*
|
|
284
|
+
* @param subscription - Stripe subscription object
|
|
285
|
+
* @see StripeSubscriptionEvent in database-types.ts for partial type
|
|
239
286
|
*/
|
|
240
287
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
|
|
241
288
|
async handleSubscriptionDeleted(subscription) {
|
|
@@ -268,7 +315,15 @@ export class BillingService {
|
|
|
268
315
|
});
|
|
269
316
|
}
|
|
270
317
|
/**
|
|
271
|
-
* Handle invoice
|
|
318
|
+
* Handle Stripe invoice.paid webhook event.
|
|
319
|
+
*
|
|
320
|
+
* Records successful payment in the invoices table. Extracts organization_id
|
|
321
|
+
* from invoice.subscription_metadata (set during checkout).
|
|
322
|
+
*
|
|
323
|
+
* Amounts are in cents (e.g., 1000 = $10.00). Currency is uppercased.
|
|
324
|
+
*
|
|
325
|
+
* @param invoice - Stripe invoice object
|
|
326
|
+
* @see StripeInvoiceEvent in database-types.ts for partial type
|
|
272
327
|
*/
|
|
273
328
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
|
|
274
329
|
async handleInvoicePaid(invoice) {
|
|
@@ -291,7 +346,16 @@ export class BillingService {
|
|
|
291
346
|
}, { onConflict: 'stripe_invoice_id' });
|
|
292
347
|
}
|
|
293
348
|
/**
|
|
294
|
-
* Handle invoice
|
|
349
|
+
* Handle Stripe invoice.payment_failed webhook event.
|
|
350
|
+
*
|
|
351
|
+
* Called when payment fails (declined card, insufficient funds, etc.).
|
|
352
|
+
* Updates organization subscription_status to 'past_due' and logs audit event.
|
|
353
|
+
*
|
|
354
|
+
* Note: Does not immediately downgrade tier. Stripe will retry payment
|
|
355
|
+
* according to your dunning settings. Downgrade happens on subscription.deleted.
|
|
356
|
+
*
|
|
357
|
+
* @param invoice - Stripe invoice object
|
|
358
|
+
* @see StripeInvoiceEvent in database-types.ts for partial type
|
|
295
359
|
*/
|
|
296
360
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
|
|
297
361
|
async handleInvoicePaymentFailed(invoice) {
|
|
@@ -358,7 +422,26 @@ export class BillingService {
|
|
|
358
422
|
return (data || []).map(this.mapDbInvoiceToInvoice);
|
|
359
423
|
}
|
|
360
424
|
/**
|
|
361
|
-
*
|
|
425
|
+
* Transform Supabase subscription record to domain model.
|
|
426
|
+
*
|
|
427
|
+
* Maps database snake_case columns to TypeScript camelCase properties:
|
|
428
|
+
* - `organization_id` → `organizationId`
|
|
429
|
+
* - `stripe_subscription_id` → `stripeSubscriptionId` (Stripe sub_xxx ID)
|
|
430
|
+
* - `stripe_price_id` → `stripePriceId` (Stripe price_xxx ID)
|
|
431
|
+
* - `stripe_product_id` → `stripeProductId` (Stripe prod_xxx ID)
|
|
432
|
+
* - `tier` → `tier` (SubscriptionTier: 'free' | 'pro' | 'enterprise')
|
|
433
|
+
* - `status` → `status` (SubscriptionStatus)
|
|
434
|
+
* - `current_period_start` → `currentPeriodStart` (nullable Date)
|
|
435
|
+
* - `current_period_end` → `currentPeriodEnd` (nullable Date)
|
|
436
|
+
* - `cancel_at_period_end` → `cancelAtPeriodEnd` (boolean)
|
|
437
|
+
* - `trial_start` → `trialStart` (nullable Date)
|
|
438
|
+
* - `trial_end` → `trialEnd` (nullable Date)
|
|
439
|
+
* - `canceled_at` → `canceledAt` (nullable Date)
|
|
440
|
+
*
|
|
441
|
+
* @param dbSub - Supabase record from 'subscriptions' table
|
|
442
|
+
* @returns Domain Subscription object
|
|
443
|
+
* @see DbSubscriptionRecord in database-types.ts for input shape
|
|
444
|
+
* @see Subscription in saas-types.ts for output shape
|
|
362
445
|
*/
|
|
363
446
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
|
|
364
447
|
mapDbSubscriptionToSubscription(dbSub) {
|
|
@@ -383,7 +466,22 @@ export class BillingService {
|
|
|
383
466
|
};
|
|
384
467
|
}
|
|
385
468
|
/**
|
|
386
|
-
*
|
|
469
|
+
* Transform Supabase invoice record to domain model.
|
|
470
|
+
*
|
|
471
|
+
* Maps database snake_case columns to TypeScript camelCase properties:
|
|
472
|
+
* - `organization_id` → `organizationId`
|
|
473
|
+
* - `stripe_invoice_id` → `stripeInvoiceId` (Stripe in_xxx ID)
|
|
474
|
+
* - `amount_due` → `amountDue` (in cents, e.g., 1000 = $10.00)
|
|
475
|
+
* - `amount_paid` → `amountPaid` (in cents)
|
|
476
|
+
* - `invoice_date` → `invoiceDate` (Date)
|
|
477
|
+
* - `due_date` → `dueDate` (nullable Date)
|
|
478
|
+
* - `paid_at` → `paidAt` (nullable Date)
|
|
479
|
+
* - `invoice_pdf_url` → `invoicePdfUrl` (Stripe-hosted PDF URL)
|
|
480
|
+
*
|
|
481
|
+
* @param dbInvoice - Supabase record from 'invoices' table
|
|
482
|
+
* @returns Domain Invoice object
|
|
483
|
+
* @see DbInvoiceRecord in database-types.ts for input shape
|
|
484
|
+
* @see Invoice in saas-types.ts for output shape
|
|
387
485
|
*/
|
|
388
486
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
|
|
389
487
|
mapDbInvoiceToInvoice(dbInvoice) {
|