lsh-framework 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +43 -3
- package/README.md +25 -4
- package/dist/cli.js +6 -0
- package/dist/commands/config.js +240 -0
- package/dist/daemon/saas-api-routes.js +778 -0
- package/dist/daemon/saas-api-server.js +225 -0
- package/dist/lib/config-manager.js +321 -0
- package/dist/lib/database-persistence.js +75 -3
- package/dist/lib/env-validator.js +17 -0
- package/dist/lib/local-storage-adapter.js +493 -0
- package/dist/lib/saas-audit.js +213 -0
- package/dist/lib/saas-auth.js +427 -0
- package/dist/lib/saas-billing.js +402 -0
- package/dist/lib/saas-email.js +402 -0
- package/dist/lib/saas-encryption.js +220 -0
- package/dist/lib/saas-organizations.js +592 -0
- package/dist/lib/saas-secrets.js +378 -0
- package/dist/lib/saas-types.js +108 -0
- package/dist/lib/supabase-client.js +77 -11
- package/package.json +13 -2
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS Email Service
|
|
3
|
+
* Email sending using Resend API
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Email Service
|
|
7
|
+
*/
|
|
8
|
+
export class EmailService {
|
|
9
|
+
config;
|
|
10
|
+
resendApiUrl = 'https://api.resend.com/emails';
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = {
|
|
13
|
+
apiKey: config?.apiKey || process.env.RESEND_API_KEY || '',
|
|
14
|
+
fromEmail: config?.fromEmail || process.env.EMAIL_FROM || 'noreply@lsh.dev',
|
|
15
|
+
fromName: config?.fromName || 'LSH Secrets Manager',
|
|
16
|
+
baseUrl: config?.baseUrl || process.env.BASE_URL || 'https://app.lsh.dev',
|
|
17
|
+
};
|
|
18
|
+
if (!this.config.apiKey) {
|
|
19
|
+
console.warn('RESEND_API_KEY not set - emails will not be sent');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Send email using Resend API
|
|
24
|
+
*/
|
|
25
|
+
async sendEmail(params) {
|
|
26
|
+
if (!this.config.apiKey) {
|
|
27
|
+
// Sanitize email parameters to prevent log injection
|
|
28
|
+
const sanitizedTo = params.to.replace(/[\r\n]/g, '');
|
|
29
|
+
const sanitizedSubject = params.subject.replace(/[\r\n]/g, '');
|
|
30
|
+
const sanitizedText = params.text.replace(/[\r\n]/g, ' ');
|
|
31
|
+
console.log('Email would be sent to:', sanitizedTo);
|
|
32
|
+
console.log('Subject:', sanitizedSubject);
|
|
33
|
+
console.log('Text:', sanitizedText);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(this.resendApiUrl, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
from: `${this.config.fromName} <${this.config.fromEmail}>`,
|
|
45
|
+
to: params.to,
|
|
46
|
+
subject: params.subject,
|
|
47
|
+
html: params.html,
|
|
48
|
+
text: params.text,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const error = await response.text();
|
|
53
|
+
throw new Error(`Failed to send email: ${error}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error('Email send error:', error);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Send email verification
|
|
63
|
+
*/
|
|
64
|
+
async sendVerificationEmail(to, token, firstName) {
|
|
65
|
+
const verificationUrl = `${this.config.baseUrl}/verify-email?token=${token}`;
|
|
66
|
+
const name = firstName || 'there';
|
|
67
|
+
const template = this.getVerificationEmailTemplate(name, verificationUrl);
|
|
68
|
+
await this.sendEmail({
|
|
69
|
+
to,
|
|
70
|
+
subject: template.subject,
|
|
71
|
+
html: template.html,
|
|
72
|
+
text: template.text,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Send password reset email
|
|
77
|
+
*/
|
|
78
|
+
async sendPasswordResetEmail(to, token, firstName) {
|
|
79
|
+
const resetUrl = `${this.config.baseUrl}/reset-password?token=${token}`;
|
|
80
|
+
const name = firstName || 'there';
|
|
81
|
+
const template = this.getPasswordResetTemplate(name, resetUrl);
|
|
82
|
+
await this.sendEmail({
|
|
83
|
+
to,
|
|
84
|
+
subject: template.subject,
|
|
85
|
+
html: template.html,
|
|
86
|
+
text: template.text,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Send organization invitation email
|
|
91
|
+
*/
|
|
92
|
+
async sendOrganizationInvite(to, organizationName, inviterName, inviteUrl) {
|
|
93
|
+
const template = this.getOrganizationInviteTemplate(organizationName, inviterName, inviteUrl);
|
|
94
|
+
await this.sendEmail({
|
|
95
|
+
to,
|
|
96
|
+
subject: template.subject,
|
|
97
|
+
html: template.html,
|
|
98
|
+
text: template.text,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Send welcome email
|
|
103
|
+
*/
|
|
104
|
+
async sendWelcomeEmail(to, firstName) {
|
|
105
|
+
const name = firstName || 'there';
|
|
106
|
+
const template = this.getWelcomeEmailTemplate(name);
|
|
107
|
+
await this.sendEmail({
|
|
108
|
+
to,
|
|
109
|
+
subject: template.subject,
|
|
110
|
+
html: template.html,
|
|
111
|
+
text: template.text,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Send subscription confirmation
|
|
116
|
+
*/
|
|
117
|
+
async sendSubscriptionConfirmation(to, tier, firstName) {
|
|
118
|
+
const name = firstName || 'there';
|
|
119
|
+
const template = this.getSubscriptionConfirmationTemplate(name, tier);
|
|
120
|
+
await this.sendEmail({
|
|
121
|
+
to,
|
|
122
|
+
subject: template.subject,
|
|
123
|
+
html: template.html,
|
|
124
|
+
text: template.text,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Email verification template
|
|
129
|
+
*/
|
|
130
|
+
getVerificationEmailTemplate(name, verificationUrl) {
|
|
131
|
+
return {
|
|
132
|
+
subject: 'Verify your email address',
|
|
133
|
+
html: `
|
|
134
|
+
<!DOCTYPE html>
|
|
135
|
+
<html>
|
|
136
|
+
<head>
|
|
137
|
+
<meta charset="utf-8">
|
|
138
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
139
|
+
</head>
|
|
140
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
141
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
142
|
+
<h1 style="color: white; margin: 0; font-size: 32px;">🔐 LSH</h1>
|
|
143
|
+
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">Secrets Manager</p>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div style="background: white; padding: 40px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
147
|
+
<h2 style="color: #333; margin-top: 0;">Hi ${name}! 👋</h2>
|
|
148
|
+
|
|
149
|
+
<p>Thanks for signing up for LSH Secrets Manager. Please verify your email address to get started.</p>
|
|
150
|
+
|
|
151
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
152
|
+
<a href="${verificationUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
|
153
|
+
Verify Email Address
|
|
154
|
+
</a>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<p style="color: #666; font-size: 14px;">Or copy and paste this link into your browser:</p>
|
|
158
|
+
<p style="color: #667eea; font-size: 12px; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">${verificationUrl}</p>
|
|
159
|
+
|
|
160
|
+
<p style="color: #666; font-size: 14px; margin-top: 30px;">This link will expire in 24 hours.</p>
|
|
161
|
+
|
|
162
|
+
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
|
|
163
|
+
|
|
164
|
+
<p style="color: #999; font-size: 12px; margin: 0;">If you didn't create an account, you can safely ignore this email.</p>
|
|
165
|
+
</div>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
168
|
+
`,
|
|
169
|
+
text: `
|
|
170
|
+
Hi ${name}!
|
|
171
|
+
|
|
172
|
+
Thanks for signing up for LSH Secrets Manager. Please verify your email address to get started.
|
|
173
|
+
|
|
174
|
+
Verification link: ${verificationUrl}
|
|
175
|
+
|
|
176
|
+
This link will expire in 24 hours.
|
|
177
|
+
|
|
178
|
+
If you didn't create an account, you can safely ignore this email.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
LSH Secrets Manager
|
|
182
|
+
`.trim(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Password reset template
|
|
187
|
+
*/
|
|
188
|
+
getPasswordResetTemplate(name, resetUrl) {
|
|
189
|
+
return {
|
|
190
|
+
subject: 'Reset your password',
|
|
191
|
+
html: `
|
|
192
|
+
<!DOCTYPE html>
|
|
193
|
+
<html>
|
|
194
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
195
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
196
|
+
<h1 style="color: white; margin: 0; font-size: 32px;">🔐 LSH</h1>
|
|
197
|
+
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Secrets Manager</p>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div style="background: white; padding: 40px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
201
|
+
<h2 style="color: #333; margin-top: 0;">Hi ${name},</h2>
|
|
202
|
+
|
|
203
|
+
<p>We received a request to reset your password. Click the button below to create a new password.</p>
|
|
204
|
+
|
|
205
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
206
|
+
<a href="${resetUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
|
207
|
+
Reset Password
|
|
208
|
+
</a>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<p style="color: #666; font-size: 14px;">Or copy and paste this link:</p>
|
|
212
|
+
<p style="color: #667eea; font-size: 12px; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">${resetUrl}</p>
|
|
213
|
+
|
|
214
|
+
<p style="color: #666; font-size: 14px; margin-top: 30px;">This link will expire in 1 hour.</p>
|
|
215
|
+
|
|
216
|
+
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
|
|
217
|
+
|
|
218
|
+
<p style="color: #999; font-size: 12px;">If you didn't request a password reset, you can safely ignore this email.</p>
|
|
219
|
+
</div>
|
|
220
|
+
</body>
|
|
221
|
+
</html>
|
|
222
|
+
`,
|
|
223
|
+
text: `
|
|
224
|
+
Hi ${name},
|
|
225
|
+
|
|
226
|
+
We received a request to reset your password. Use the link below to create a new password:
|
|
227
|
+
|
|
228
|
+
${resetUrl}
|
|
229
|
+
|
|
230
|
+
This link will expire in 1 hour.
|
|
231
|
+
|
|
232
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
LSH Secrets Manager
|
|
236
|
+
`.trim(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Organization invite template
|
|
241
|
+
*/
|
|
242
|
+
getOrganizationInviteTemplate(organizationName, inviterName, inviteUrl) {
|
|
243
|
+
return {
|
|
244
|
+
subject: `You've been invited to join ${organizationName} on LSH`,
|
|
245
|
+
html: `
|
|
246
|
+
<!DOCTYPE html>
|
|
247
|
+
<html>
|
|
248
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
249
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
250
|
+
<h1 style="color: white; margin: 0; font-size: 32px;">🔐 LSH</h1>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div style="background: white; padding: 40px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
254
|
+
<h2 style="color: #333; margin-top: 0;">You've been invited! 🎉</h2>
|
|
255
|
+
|
|
256
|
+
<p><strong>${inviterName}</strong> has invited you to join <strong>${organizationName}</strong> on LSH Secrets Manager.</p>
|
|
257
|
+
|
|
258
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
259
|
+
<a href="${inviteUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
|
260
|
+
Accept Invitation
|
|
261
|
+
</a>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<p style="color: #666; font-size: 14px;">Or copy and paste this link:</p>
|
|
265
|
+
<p style="color: #667eea; font-size: 12px; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">${inviteUrl}</p>
|
|
266
|
+
</div>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
|
269
|
+
`,
|
|
270
|
+
text: `
|
|
271
|
+
You've been invited!
|
|
272
|
+
|
|
273
|
+
${inviterName} has invited you to join ${organizationName} on LSH Secrets Manager.
|
|
274
|
+
|
|
275
|
+
Accept invitation: ${inviteUrl}
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
LSH Secrets Manager
|
|
279
|
+
`.trim(),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Welcome email template
|
|
284
|
+
*/
|
|
285
|
+
getWelcomeEmailTemplate(name) {
|
|
286
|
+
const dashboardUrl = `${this.config.baseUrl}/dashboard`;
|
|
287
|
+
const docsUrl = 'https://docs.lsh.dev';
|
|
288
|
+
return {
|
|
289
|
+
subject: 'Welcome to LSH Secrets Manager! 🚀',
|
|
290
|
+
html: `
|
|
291
|
+
<!DOCTYPE html>
|
|
292
|
+
<html>
|
|
293
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
294
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
295
|
+
<h1 style="color: white; margin: 0; font-size: 32px;">🔐 LSH</h1>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div style="background: white; padding: 40px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
299
|
+
<h2 style="color: #333; margin-top: 0;">Welcome, ${name}! 🎉</h2>
|
|
300
|
+
|
|
301
|
+
<p>Your account is now active. Here's how to get started:</p>
|
|
302
|
+
|
|
303
|
+
<ol style="color: #666;">
|
|
304
|
+
<li><strong>Create a team</strong> - Organize your secrets by project or environment</li>
|
|
305
|
+
<li><strong>Add secrets</strong> - Securely store API keys, tokens, and credentials</li>
|
|
306
|
+
<li><strong>Invite team members</strong> - Collaborate securely with your team</li>
|
|
307
|
+
<li><strong>Install the CLI</strong> - <code>npm install -g lsh</code></li>
|
|
308
|
+
</ol>
|
|
309
|
+
|
|
310
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
311
|
+
<a href="${dashboardUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; margin-right: 10px;">
|
|
312
|
+
Go to Dashboard
|
|
313
|
+
</a>
|
|
314
|
+
<a href="${docsUrl}" style="background: white; color: #667eea; border: 2px solid #667eea; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
|
315
|
+
View Docs
|
|
316
|
+
</a>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<p style="color: #666;">Need help? Reply to this email or check out our <a href="${docsUrl}" style="color: #667eea;">documentation</a>.</p>
|
|
320
|
+
</div>
|
|
321
|
+
</body>
|
|
322
|
+
</html>
|
|
323
|
+
`,
|
|
324
|
+
text: `
|
|
325
|
+
Welcome to LSH Secrets Manager, ${name}!
|
|
326
|
+
|
|
327
|
+
Your account is now active. Here's how to get started:
|
|
328
|
+
|
|
329
|
+
1. Create a team - Organize your secrets by project or environment
|
|
330
|
+
2. Add secrets - Securely store API keys, tokens, and credentials
|
|
331
|
+
3. Invite team members - Collaborate securely with your team
|
|
332
|
+
4. Install the CLI - npm install -g lsh
|
|
333
|
+
|
|
334
|
+
Dashboard: ${dashboardUrl}
|
|
335
|
+
Documentation: ${docsUrl}
|
|
336
|
+
|
|
337
|
+
Need help? Reply to this email or check out our documentation.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
LSH Secrets Manager
|
|
341
|
+
`.trim(),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Subscription confirmation template
|
|
346
|
+
*/
|
|
347
|
+
getSubscriptionConfirmationTemplate(name, tier) {
|
|
348
|
+
return {
|
|
349
|
+
subject: `Your ${tier} subscription is active! 🎉`,
|
|
350
|
+
html: `
|
|
351
|
+
<!DOCTYPE html>
|
|
352
|
+
<html>
|
|
353
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
354
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
355
|
+
<h1 style="color: white; margin: 0; font-size: 32px;">🔐 LSH</h1>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div style="background: white; padding: 40px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
359
|
+
<h2 style="color: #333; margin-top: 0;">Thanks, ${name}! 🎉</h2>
|
|
360
|
+
|
|
361
|
+
<p>Your <strong>${tier}</strong> subscription is now active. You now have access to:</p>
|
|
362
|
+
|
|
363
|
+
<ul style="color: #666;">
|
|
364
|
+
${tier === 'Pro'
|
|
365
|
+
? `
|
|
366
|
+
<li>Unlimited team members</li>
|
|
367
|
+
<li>Unlimited secrets</li>
|
|
368
|
+
<li>Unlimited environments</li>
|
|
369
|
+
<li>1-year audit log retention</li>
|
|
370
|
+
<li>Priority support</li>
|
|
371
|
+
`
|
|
372
|
+
: `
|
|
373
|
+
<li>Multiple organizations</li>
|
|
374
|
+
<li>SSO/SAML integration</li>
|
|
375
|
+
<li>Unlimited audit log retention</li>
|
|
376
|
+
<li>SLA support</li>
|
|
377
|
+
<li>On-premise deployment option</li>
|
|
378
|
+
`}
|
|
379
|
+
</ul>
|
|
380
|
+
|
|
381
|
+
<p style="color: #666;">Manage your subscription anytime from your account settings.</p>
|
|
382
|
+
</div>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|
|
385
|
+
`,
|
|
386
|
+
text: `
|
|
387
|
+
Thanks, ${name}!
|
|
388
|
+
|
|
389
|
+
Your ${tier} subscription is now active.
|
|
390
|
+
|
|
391
|
+
Manage your subscription anytime from your account settings.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
LSH Secrets Manager
|
|
395
|
+
`.trim(),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Singleton instance
|
|
401
|
+
*/
|
|
402
|
+
export const emailService = new EmailService();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS Per-Team Encryption Service
|
|
3
|
+
* Manages encryption keys for each team
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash, pbkdf2Sync } from 'crypto';
|
|
6
|
+
import { getSupabaseClient } from './supabase-client.js';
|
|
7
|
+
const ALGORITHM = 'aes-256-cbc';
|
|
8
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
9
|
+
const IV_LENGTH = 16; // 128 bits
|
|
10
|
+
const SALT_LENGTH = 32;
|
|
11
|
+
const PBKDF2_ITERATIONS = 100000;
|
|
12
|
+
/**
|
|
13
|
+
* Get master encryption key from environment
|
|
14
|
+
* This key is used to encrypt/decrypt team encryption keys
|
|
15
|
+
*/
|
|
16
|
+
function getMasterKey() {
|
|
17
|
+
const masterKeyHex = process.env.LSH_MASTER_KEY || process.env.LSH_SECRETS_KEY;
|
|
18
|
+
if (!masterKeyHex) {
|
|
19
|
+
throw new Error('LSH_MASTER_KEY or LSH_SECRETS_KEY environment variable must be set for encryption');
|
|
20
|
+
}
|
|
21
|
+
// If it's a hex string, convert it
|
|
22
|
+
if (/^[0-9a-fA-F]+$/.test(masterKeyHex)) {
|
|
23
|
+
return Buffer.from(masterKeyHex, 'hex');
|
|
24
|
+
}
|
|
25
|
+
// Otherwise, derive a key from it using PBKDF2
|
|
26
|
+
const salt = createHash('sha256').update('lsh-saas-master-key-salt').digest();
|
|
27
|
+
return pbkdf2Sync(masterKeyHex, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Encryption Service
|
|
31
|
+
*/
|
|
32
|
+
export class EncryptionService {
|
|
33
|
+
supabase = getSupabaseClient();
|
|
34
|
+
masterKey;
|
|
35
|
+
constructor() {
|
|
36
|
+
this.masterKey = getMasterKey();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate a new encryption key for a team
|
|
40
|
+
*/
|
|
41
|
+
async generateTeamKey(teamId, createdBy) {
|
|
42
|
+
// Generate random key
|
|
43
|
+
const teamKey = randomBytes(KEY_LENGTH);
|
|
44
|
+
// Encrypt the team key with the master key
|
|
45
|
+
const encryptedKey = this.encryptWithMasterKey(teamKey);
|
|
46
|
+
// Store in database
|
|
47
|
+
const { data, error } = await this.supabase
|
|
48
|
+
.from('encryption_keys')
|
|
49
|
+
.insert({
|
|
50
|
+
team_id: teamId,
|
|
51
|
+
encrypted_key: encryptedKey,
|
|
52
|
+
key_version: 1,
|
|
53
|
+
algorithm: ALGORITHM,
|
|
54
|
+
is_active: true,
|
|
55
|
+
created_by: createdBy,
|
|
56
|
+
})
|
|
57
|
+
.select()
|
|
58
|
+
.single();
|
|
59
|
+
if (error) {
|
|
60
|
+
throw new Error(`Failed to create encryption key: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
// Update team to use this key
|
|
63
|
+
await this.supabase
|
|
64
|
+
.from('teams')
|
|
65
|
+
.update({ encryption_key_id: data.id })
|
|
66
|
+
.eq('id', teamId);
|
|
67
|
+
return this.mapDbKeyToKey(data);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Rotate team encryption key
|
|
71
|
+
*/
|
|
72
|
+
async rotateTeamKey(teamId, rotatedBy) {
|
|
73
|
+
// Get current key version
|
|
74
|
+
const { data: currentKeys } = await this.supabase
|
|
75
|
+
.from('encryption_keys')
|
|
76
|
+
.select('key_version')
|
|
77
|
+
.eq('team_id', teamId)
|
|
78
|
+
.order('key_version', { ascending: false })
|
|
79
|
+
.limit(1);
|
|
80
|
+
const newVersion = currentKeys && currentKeys.length > 0 ? currentKeys[0].key_version + 1 : 1;
|
|
81
|
+
// Mark old keys as inactive
|
|
82
|
+
await this.supabase
|
|
83
|
+
.from('encryption_keys')
|
|
84
|
+
.update({
|
|
85
|
+
is_active: false,
|
|
86
|
+
rotated_at: new Date().toISOString(),
|
|
87
|
+
})
|
|
88
|
+
.eq('team_id', teamId)
|
|
89
|
+
.eq('is_active', true);
|
|
90
|
+
// Generate new key
|
|
91
|
+
const teamKey = randomBytes(KEY_LENGTH);
|
|
92
|
+
const encryptedKey = this.encryptWithMasterKey(teamKey);
|
|
93
|
+
// Store new key
|
|
94
|
+
const { data, error } = await this.supabase
|
|
95
|
+
.from('encryption_keys')
|
|
96
|
+
.insert({
|
|
97
|
+
team_id: teamId,
|
|
98
|
+
encrypted_key: encryptedKey,
|
|
99
|
+
key_version: newVersion,
|
|
100
|
+
algorithm: ALGORITHM,
|
|
101
|
+
is_active: true,
|
|
102
|
+
created_by: rotatedBy,
|
|
103
|
+
})
|
|
104
|
+
.select()
|
|
105
|
+
.single();
|
|
106
|
+
if (error) {
|
|
107
|
+
throw new Error(`Failed to rotate encryption key: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
// Update team
|
|
110
|
+
await this.supabase
|
|
111
|
+
.from('teams')
|
|
112
|
+
.update({ encryption_key_id: data.id })
|
|
113
|
+
.eq('id', teamId);
|
|
114
|
+
return this.mapDbKeyToKey(data);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Get active encryption key for a team
|
|
118
|
+
*/
|
|
119
|
+
async getTeamKey(teamId) {
|
|
120
|
+
const { data, error } = await this.supabase
|
|
121
|
+
.from('encryption_keys')
|
|
122
|
+
.select('*')
|
|
123
|
+
.eq('team_id', teamId)
|
|
124
|
+
.eq('is_active', true)
|
|
125
|
+
.single();
|
|
126
|
+
if (error || !data) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return this.mapDbKeyToKey(data);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get decrypted team key (for encryption/decryption operations)
|
|
133
|
+
*/
|
|
134
|
+
async getDecryptedTeamKey(teamId) {
|
|
135
|
+
const key = await this.getTeamKey(teamId);
|
|
136
|
+
if (!key) {
|
|
137
|
+
throw new Error('No active encryption key found for team');
|
|
138
|
+
}
|
|
139
|
+
return this.decryptWithMasterKey(key.encryptedKey);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Encrypt data with team's key
|
|
143
|
+
*/
|
|
144
|
+
async encryptForTeam(teamId, data) {
|
|
145
|
+
const teamKey = await this.getDecryptedTeamKey(teamId);
|
|
146
|
+
// Generate random IV
|
|
147
|
+
const iv = randomBytes(IV_LENGTH);
|
|
148
|
+
// Encrypt
|
|
149
|
+
const cipher = createCipheriv(ALGORITHM, teamKey, iv);
|
|
150
|
+
let encrypted = cipher.update(data, 'utf8', 'hex');
|
|
151
|
+
encrypted += cipher.final('hex');
|
|
152
|
+
// Return IV + encrypted data
|
|
153
|
+
return iv.toString('hex') + ':' + encrypted;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Decrypt data with team's key
|
|
157
|
+
*/
|
|
158
|
+
async decryptForTeam(teamId, encryptedData) {
|
|
159
|
+
const teamKey = await this.getDecryptedTeamKey(teamId);
|
|
160
|
+
// Split IV and encrypted data
|
|
161
|
+
const parts = encryptedData.split(':');
|
|
162
|
+
if (parts.length !== 2) {
|
|
163
|
+
throw new Error('Invalid encrypted data format');
|
|
164
|
+
}
|
|
165
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
166
|
+
const encrypted = parts[1];
|
|
167
|
+
// Decrypt
|
|
168
|
+
const decipher = createDecipheriv(ALGORITHM, teamKey, iv);
|
|
169
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
170
|
+
decrypted += decipher.final('utf8');
|
|
171
|
+
return decrypted;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Encrypt team key with master key
|
|
175
|
+
*/
|
|
176
|
+
encryptWithMasterKey(teamKey) {
|
|
177
|
+
const iv = randomBytes(IV_LENGTH);
|
|
178
|
+
const cipher = createCipheriv(ALGORITHM, this.masterKey, iv);
|
|
179
|
+
let encrypted = cipher.update(teamKey);
|
|
180
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
181
|
+
// Return IV + encrypted key
|
|
182
|
+
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Decrypt team key with master key
|
|
186
|
+
*/
|
|
187
|
+
decryptWithMasterKey(encryptedKey) {
|
|
188
|
+
const parts = encryptedKey.split(':');
|
|
189
|
+
if (parts.length !== 2) {
|
|
190
|
+
throw new Error('Invalid encrypted key format');
|
|
191
|
+
}
|
|
192
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
193
|
+
const encrypted = Buffer.from(parts[1], 'hex');
|
|
194
|
+
const decipher = createDecipheriv(ALGORITHM, this.masterKey, iv);
|
|
195
|
+
let decrypted = decipher.update(encrypted);
|
|
196
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
197
|
+
return decrypted;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Map database key to EncryptionKey type
|
|
201
|
+
*/
|
|
202
|
+
mapDbKeyToKey(dbKey) {
|
|
203
|
+
return {
|
|
204
|
+
id: dbKey.id,
|
|
205
|
+
teamId: dbKey.team_id,
|
|
206
|
+
encryptedKey: dbKey.encrypted_key,
|
|
207
|
+
keyVersion: dbKey.key_version,
|
|
208
|
+
algorithm: dbKey.algorithm,
|
|
209
|
+
isActive: dbKey.is_active,
|
|
210
|
+
rotatedAt: dbKey.rotated_at ? new Date(dbKey.rotated_at) : null,
|
|
211
|
+
expiresAt: dbKey.expires_at ? new Date(dbKey.expires_at) : null,
|
|
212
|
+
createdAt: new Date(dbKey.created_at),
|
|
213
|
+
createdBy: dbKey.created_by,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Singleton instance
|
|
219
|
+
*/
|
|
220
|
+
export const encryptionService = new EncryptionService();
|