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.
@@ -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.loadMetadata();
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 delete(environment, gitRepo) {
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
- if (fs.existsSync(cachePath)) {
248
- fs.unlinkSync(cachePath);
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
- fs.writeFileSync(cachePath, encryptedData, 'utf8');
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
- if (!fs.existsSync(cachePath)) {
336
+ try {
337
+ await fsPromises.access(cachePath);
338
+ }
339
+ catch {
322
340
  return null;
323
341
  }
324
- return fs.readFileSync(cachePath, 'utf8');
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
- fs.writeFileSync(this.metadataPath, JSON.stringify(this.metadata, null, 2), 'utf8');
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
+ }
@@ -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
- * Map database user to User type
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
- * Map database organization to Organization type
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) {