lsh-framework 3.1.6 → 3.1.8
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/ipfs-secrets-storage.js +54 -14
- 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/dist/services/secrets/secrets.js +8 -5
- package/package.json +6 -4
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Stores encrypted secrets on IPFS using Storacha (formerly web3.storage)
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as fsPromises from 'fs/promises';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
import * as os from 'os';
|
|
8
9
|
import * as crypto from 'crypto';
|
|
@@ -30,12 +31,19 @@ export class IPFSSecretsStorage {
|
|
|
30
31
|
const lshDir = path.join(homeDir, '.lsh');
|
|
31
32
|
this.cacheDir = path.join(lshDir, 'secrets-cache');
|
|
32
33
|
this.metadataPath = path.join(lshDir, 'secrets-metadata.json');
|
|
34
|
+
// Initialize metadata - will be loaded on first use
|
|
35
|
+
this.metadata = {};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Initialize async parts
|
|
39
|
+
*/
|
|
40
|
+
async initialize() {
|
|
33
41
|
// Ensure directories exist
|
|
34
42
|
if (!fs.existsSync(this.cacheDir)) {
|
|
35
43
|
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
36
44
|
}
|
|
37
45
|
// Load metadata
|
|
38
|
-
this.metadata = this.
|
|
46
|
+
this.metadata = await this.loadMetadataAsync();
|
|
39
47
|
}
|
|
40
48
|
/**
|
|
41
49
|
* Store secrets on IPFS
|
|
@@ -59,7 +67,7 @@ export class IPFSSecretsStorage {
|
|
|
59
67
|
encrypted: true,
|
|
60
68
|
};
|
|
61
69
|
this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
|
|
62
|
-
this.saveMetadata();
|
|
70
|
+
await this.saveMetadata();
|
|
63
71
|
logger.info(`📦 Stored ${secrets.length} secrets on IPFS: ${cid}`);
|
|
64
72
|
logger.info(` Environment: ${environment}`);
|
|
65
73
|
if (gitRepo) {
|
|
@@ -132,7 +140,7 @@ export class IPFSSecretsStorage {
|
|
|
132
140
|
encrypted: true,
|
|
133
141
|
};
|
|
134
142
|
this.metadata[metadataKey] = metadata;
|
|
135
|
-
this.saveMetadata();
|
|
143
|
+
await this.saveMetadata();
|
|
136
144
|
}
|
|
137
145
|
}
|
|
138
146
|
}
|
|
@@ -162,7 +170,7 @@ export class IPFSSecretsStorage {
|
|
|
162
170
|
timestamp: new Date().toISOString(),
|
|
163
171
|
};
|
|
164
172
|
this.metadata[metadataKey] = metadata;
|
|
165
|
-
this.saveMetadata();
|
|
173
|
+
await this.saveMetadata();
|
|
166
174
|
}
|
|
167
175
|
}
|
|
168
176
|
}
|
|
@@ -236,20 +244,24 @@ export class IPFSSecretsStorage {
|
|
|
236
244
|
return Object.values(this.metadata);
|
|
237
245
|
}
|
|
238
246
|
/**
|
|
239
|
-
* Delete secrets for environment
|
|
247
|
+
* Delete local cached secrets for an environment
|
|
240
248
|
*/
|
|
241
|
-
async
|
|
249
|
+
async deleteLocal(environment, gitRepo) {
|
|
242
250
|
const metadataKey = this.getMetadataKey(gitRepo, environment);
|
|
243
251
|
const metadata = this.metadata[metadataKey];
|
|
244
252
|
if (metadata) {
|
|
245
253
|
// Delete local cache
|
|
246
254
|
const cachePath = path.join(this.cacheDir, `${metadata.cid}.encrypted`);
|
|
247
|
-
|
|
248
|
-
|
|
255
|
+
try {
|
|
256
|
+
await fsPromises.access(cachePath);
|
|
257
|
+
await fsPromises.unlink(cachePath);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// File doesn't exist, which is fine
|
|
249
261
|
}
|
|
250
262
|
// Remove metadata
|
|
251
263
|
delete this.metadata[metadataKey];
|
|
252
|
-
this.saveMetadata();
|
|
264
|
+
await this.saveMetadata();
|
|
253
265
|
logger.info(`🗑️ Deleted secrets for ${environment}`);
|
|
254
266
|
}
|
|
255
267
|
}
|
|
@@ -310,7 +322,10 @@ export class IPFSSecretsStorage {
|
|
|
310
322
|
*/
|
|
311
323
|
async storeLocally(cid, encryptedData, _environment) {
|
|
312
324
|
const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
|
|
313
|
-
|
|
325
|
+
// Ensure parent directory exists
|
|
326
|
+
await fsPromises.mkdir(this.cacheDir, { recursive: true });
|
|
327
|
+
// Write file without locking (simpler approach)
|
|
328
|
+
await fsPromises.writeFile(cachePath, encryptedData, 'utf8');
|
|
314
329
|
logger.debug(`Cached secrets locally: ${cachePath}`);
|
|
315
330
|
}
|
|
316
331
|
/**
|
|
@@ -318,10 +333,14 @@ export class IPFSSecretsStorage {
|
|
|
318
333
|
*/
|
|
319
334
|
async loadLocally(cid) {
|
|
320
335
|
const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
|
|
321
|
-
|
|
336
|
+
try {
|
|
337
|
+
await fsPromises.access(cachePath);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
322
340
|
return null;
|
|
323
341
|
}
|
|
324
|
-
|
|
342
|
+
// Simple read without locking for now
|
|
343
|
+
return await fsPromises.readFile(cachePath, 'utf8');
|
|
325
344
|
}
|
|
326
345
|
/**
|
|
327
346
|
* Get metadata key for environment
|
|
@@ -344,10 +363,31 @@ export class IPFSSecretsStorage {
|
|
|
344
363
|
return {};
|
|
345
364
|
}
|
|
346
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Load metadata from disk asynchronously
|
|
368
|
+
*/
|
|
369
|
+
async loadMetadataAsync() {
|
|
370
|
+
try {
|
|
371
|
+
await fsPromises.access(this.metadataPath);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const content = await fsPromises.readFile(this.metadataPath, 'utf8');
|
|
378
|
+
return JSON.parse(content);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return {};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
347
384
|
/**
|
|
348
385
|
* Save metadata to disk
|
|
349
386
|
*/
|
|
350
|
-
saveMetadata() {
|
|
351
|
-
|
|
387
|
+
async saveMetadata() {
|
|
388
|
+
// Ensure parent directory exists
|
|
389
|
+
const parentDir = path.dirname(this.metadataPath);
|
|
390
|
+
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
391
|
+
await fsPromises.writeFile(this.metadataPath, JSON.stringify(this.metadata, null, 2), 'utf8');
|
|
352
392
|
}
|
|
353
393
|
}
|
|
@@ -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) {
|