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,778 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS API Routes
|
|
3
|
+
* RESTful API endpoints for the SaaS platform
|
|
4
|
+
*/
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import { authService, verifyToken } from '../lib/saas-auth.js';
|
|
7
|
+
import { organizationService, teamService } from '../lib/saas-organizations.js';
|
|
8
|
+
import { billingService } from '../lib/saas-billing.js';
|
|
9
|
+
import { auditLogger, getIpFromRequest } from '../lib/saas-audit.js';
|
|
10
|
+
import { emailService } from '../lib/saas-email.js';
|
|
11
|
+
import { secretsService } from '../lib/saas-secrets.js';
|
|
12
|
+
/**
|
|
13
|
+
* Rate Limiters for different endpoint types
|
|
14
|
+
*/
|
|
15
|
+
// Strict rate limiter for authentication endpoints (5 requests per 15 minutes)
|
|
16
|
+
const authLimiter = rateLimit({
|
|
17
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
18
|
+
max: 5,
|
|
19
|
+
message: {
|
|
20
|
+
success: false,
|
|
21
|
+
error: {
|
|
22
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
23
|
+
message: 'Too many authentication attempts, please try again later',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
standardHeaders: true,
|
|
27
|
+
legacyHeaders: false,
|
|
28
|
+
});
|
|
29
|
+
// Moderate rate limiter for write operations (30 requests per 15 minutes)
|
|
30
|
+
const writeLimiter = rateLimit({
|
|
31
|
+
windowMs: 15 * 60 * 1000,
|
|
32
|
+
max: 30,
|
|
33
|
+
message: {
|
|
34
|
+
success: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
37
|
+
message: 'Too many write requests, please try again later',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
standardHeaders: true,
|
|
41
|
+
legacyHeaders: false,
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Middleware: Authenticate user from JWT token
|
|
45
|
+
* Security is enforced by cryptographic JWT verification and database lookup,
|
|
46
|
+
* not by input validation alone.
|
|
47
|
+
*/
|
|
48
|
+
export async function authenticateUser(req, res, next) {
|
|
49
|
+
try {
|
|
50
|
+
const authHeader = req.headers.authorization;
|
|
51
|
+
// Early rejection for malformed requests (not a security boundary)
|
|
52
|
+
// Real security comes from JWT cryptographic verification below
|
|
53
|
+
if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
|
|
54
|
+
return res.status(401).json({
|
|
55
|
+
success: false,
|
|
56
|
+
error: {
|
|
57
|
+
code: 'UNAUTHORIZED',
|
|
58
|
+
message: 'No authorization token provided',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const token = authHeader.substring(7).trim();
|
|
63
|
+
// SECURITY BOUNDARY: Cryptographic JWT verification
|
|
64
|
+
// This uses server-side secret to verify token authenticity
|
|
65
|
+
// User cannot bypass this by manipulating input
|
|
66
|
+
const { userId } = verifyToken(token);
|
|
67
|
+
const user = await authService.getUserById(userId);
|
|
68
|
+
if (!user) {
|
|
69
|
+
return res.status(401).json({
|
|
70
|
+
success: false,
|
|
71
|
+
error: {
|
|
72
|
+
code: 'UNAUTHORIZED',
|
|
73
|
+
message: 'Invalid token',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Attach user to request
|
|
78
|
+
req.user = user;
|
|
79
|
+
next();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return res.status(401).json({
|
|
83
|
+
success: false,
|
|
84
|
+
error: {
|
|
85
|
+
code: 'UNAUTHORIZED',
|
|
86
|
+
message: error.message || 'Authentication failed',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Middleware: Check organization membership
|
|
93
|
+
*/
|
|
94
|
+
export async function requireOrganizationMembership(req, res, next) {
|
|
95
|
+
try {
|
|
96
|
+
// Only use organizationId from URL params to prevent bypass attacks
|
|
97
|
+
const organizationId = req.params.organizationId;
|
|
98
|
+
if (!organizationId) {
|
|
99
|
+
return res.status(400).json({
|
|
100
|
+
success: false,
|
|
101
|
+
error: { code: 'INVALID_INPUT', message: 'Organization ID required' },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const role = await organizationService.getUserRole(organizationId, req.user.id);
|
|
105
|
+
if (!role) {
|
|
106
|
+
return res.status(403).json({
|
|
107
|
+
success: false,
|
|
108
|
+
error: { code: 'FORBIDDEN', message: 'Not a member of this organization' },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
req.organizationRole = role;
|
|
112
|
+
next();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
return res.status(500).json({
|
|
116
|
+
success: false,
|
|
117
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Setup SaaS API routes
|
|
123
|
+
*/
|
|
124
|
+
export function setupSaaSApiRoutes(app) {
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// AUTH ROUTES
|
|
127
|
+
// ============================================================================
|
|
128
|
+
/**
|
|
129
|
+
* POST /api/v1/auth/signup
|
|
130
|
+
* Sign up a new user
|
|
131
|
+
*/
|
|
132
|
+
app.post('/api/v1/auth/signup', authLimiter, async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const { email, password, firstName, lastName } = req.body;
|
|
135
|
+
// Input sanitization (not a security boundary)
|
|
136
|
+
// Real validation happens in authService.signup()
|
|
137
|
+
if (typeof email !== 'string' || typeof password !== 'string' ||
|
|
138
|
+
email.trim().length === 0 || password.length < 8) {
|
|
139
|
+
return res.status(400).json({
|
|
140
|
+
success: false,
|
|
141
|
+
error: { code: 'INVALID_INPUT', message: 'Valid email and password (min 8 chars) required' },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Basic email format validation (fail-fast for obviously bad input)
|
|
145
|
+
// Real security: authService validates and prevents duplicate emails in database
|
|
146
|
+
if (email.length > 254 || // RFC 5321 max email length
|
|
147
|
+
!email.includes('@') ||
|
|
148
|
+
email.indexOf('@') !== email.lastIndexOf('@') || // Exactly one @
|
|
149
|
+
email.startsWith('@') ||
|
|
150
|
+
email.endsWith('@') ||
|
|
151
|
+
email.split('@')[1]?.includes('.') === false) { // Domain has a dot
|
|
152
|
+
return res.status(400).json({
|
|
153
|
+
success: false,
|
|
154
|
+
error: { code: 'INVALID_INPUT', message: 'Invalid email format' },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// SECURITY BOUNDARY: authService handles:
|
|
158
|
+
// - Password hashing with bcrypt
|
|
159
|
+
// - Database uniqueness constraints
|
|
160
|
+
// - Email verification token generation
|
|
161
|
+
const { user, verificationToken } = await authService.signup({
|
|
162
|
+
email,
|
|
163
|
+
password,
|
|
164
|
+
firstName,
|
|
165
|
+
lastName,
|
|
166
|
+
});
|
|
167
|
+
// Send verification email
|
|
168
|
+
await emailService.sendVerificationEmail(user.email, verificationToken, user.firstName || undefined);
|
|
169
|
+
res.json({
|
|
170
|
+
success: true,
|
|
171
|
+
data: {
|
|
172
|
+
user: {
|
|
173
|
+
id: user.id,
|
|
174
|
+
email: user.email,
|
|
175
|
+
firstName: user.firstName,
|
|
176
|
+
lastName: user.lastName,
|
|
177
|
+
emailVerified: user.emailVerified,
|
|
178
|
+
},
|
|
179
|
+
message: 'Verification email sent',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
res.status(400).json({
|
|
185
|
+
success: false,
|
|
186
|
+
error: { code: error.message.includes('ALREADY_EXISTS') ? 'EMAIL_ALREADY_EXISTS' : 'INTERNAL_ERROR', message: error.message },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
/**
|
|
191
|
+
* POST /api/v1/auth/login
|
|
192
|
+
* Login with email and password
|
|
193
|
+
*/
|
|
194
|
+
app.post('/api/v1/auth/login', authLimiter, async (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const { email, password } = req.body;
|
|
197
|
+
// Input sanitization (not a security boundary)
|
|
198
|
+
// Real authentication happens in authService.login() via bcrypt password comparison
|
|
199
|
+
if (typeof email !== 'string' || typeof password !== 'string' ||
|
|
200
|
+
email.trim().length === 0 || password.length === 0) {
|
|
201
|
+
return res.status(400).json({
|
|
202
|
+
success: false,
|
|
203
|
+
error: { code: 'INVALID_INPUT', message: 'Email and password required' },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
// SECURITY BOUNDARY: authService.login() performs:
|
|
207
|
+
// - Database lookup for user by email
|
|
208
|
+
// - bcrypt password verification (cryptographic)
|
|
209
|
+
// - Session token generation with server-side secret
|
|
210
|
+
const ipAddress = getIpFromRequest(req);
|
|
211
|
+
const session = await authService.login({ email, password }, ipAddress);
|
|
212
|
+
res.json({
|
|
213
|
+
success: true,
|
|
214
|
+
data: session,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
const statusCode = error.message.includes('EMAIL_NOT_VERIFIED') ? 403 : 401;
|
|
219
|
+
res.status(statusCode).json({
|
|
220
|
+
success: false,
|
|
221
|
+
error: { code: error.message || 'INVALID_CREDENTIALS', message: error.message },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
/**
|
|
226
|
+
* POST /api/v1/auth/verify-email
|
|
227
|
+
* Verify email address
|
|
228
|
+
*/
|
|
229
|
+
app.post('/api/v1/auth/verify-email', authLimiter, async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const { token } = req.body;
|
|
232
|
+
// Input sanitization (not a security boundary)
|
|
233
|
+
// Real verification happens in authService.verifyEmail() via database lookup
|
|
234
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
235
|
+
return res.status(400).json({
|
|
236
|
+
success: false,
|
|
237
|
+
error: { code: 'INVALID_INPUT', message: 'Valid token required' },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// SECURITY BOUNDARY: authService.verifyEmail() performs:
|
|
241
|
+
// - Database lookup for verification token
|
|
242
|
+
// - Token expiry validation
|
|
243
|
+
// - User status update in database
|
|
244
|
+
const user = await authService.verifyEmail(token);
|
|
245
|
+
// Send welcome email
|
|
246
|
+
await emailService.sendWelcomeEmail(user.email, user.firstName || undefined);
|
|
247
|
+
res.json({
|
|
248
|
+
success: true,
|
|
249
|
+
data: { user, message: 'Email verified successfully' },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
res.status(400).json({
|
|
254
|
+
success: false,
|
|
255
|
+
error: { code: 'INVALID_TOKEN', message: error.message },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
/**
|
|
260
|
+
* POST /api/v1/auth/resend-verification
|
|
261
|
+
* Resend verification email
|
|
262
|
+
*/
|
|
263
|
+
app.post('/api/v1/auth/resend-verification', authLimiter, async (req, res) => {
|
|
264
|
+
try {
|
|
265
|
+
const { email } = req.body;
|
|
266
|
+
const token = await authService.resendVerificationEmail(email);
|
|
267
|
+
const user = await authService.getUserByEmail(email);
|
|
268
|
+
if (user) {
|
|
269
|
+
await emailService.sendVerificationEmail(email, token, user.firstName || undefined);
|
|
270
|
+
}
|
|
271
|
+
res.json({
|
|
272
|
+
success: true,
|
|
273
|
+
data: { message: 'Verification email sent' },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
res.status(400).json({
|
|
278
|
+
success: false,
|
|
279
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
/**
|
|
284
|
+
* POST /api/v1/auth/refresh
|
|
285
|
+
* Refresh access token
|
|
286
|
+
*/
|
|
287
|
+
app.post('/api/v1/auth/refresh', authLimiter, async (req, res) => {
|
|
288
|
+
try {
|
|
289
|
+
const { refreshToken } = req.body;
|
|
290
|
+
if (!refreshToken) {
|
|
291
|
+
return res.status(400).json({
|
|
292
|
+
success: false,
|
|
293
|
+
error: { code: 'INVALID_INPUT', message: 'Refresh token required' },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const tokens = await authService.refreshAccessToken(refreshToken);
|
|
297
|
+
res.json({
|
|
298
|
+
success: true,
|
|
299
|
+
data: tokens,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
res.status(401).json({
|
|
304
|
+
success: false,
|
|
305
|
+
error: { code: 'INVALID_TOKEN', message: error.message },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
/**
|
|
310
|
+
* GET /api/v1/auth/me
|
|
311
|
+
* Get current user
|
|
312
|
+
*/
|
|
313
|
+
app.get('/api/v1/auth/me', authenticateUser, async (req, res) => {
|
|
314
|
+
res.json({
|
|
315
|
+
success: true,
|
|
316
|
+
data: { user: req.user },
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// ORGANIZATION ROUTES
|
|
321
|
+
// ============================================================================
|
|
322
|
+
/**
|
|
323
|
+
* POST /api/v1/organizations
|
|
324
|
+
* Create a new organization
|
|
325
|
+
*/
|
|
326
|
+
app.post('/api/v1/organizations', writeLimiter, authenticateUser, async (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
const { name, slug } = req.body;
|
|
329
|
+
if (!name) {
|
|
330
|
+
return res.status(400).json({
|
|
331
|
+
success: false,
|
|
332
|
+
error: { code: 'INVALID_INPUT', message: 'Organization name required' },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const organization = await organizationService.createOrganization({
|
|
336
|
+
name,
|
|
337
|
+
slug,
|
|
338
|
+
ownerId: req.user.id,
|
|
339
|
+
});
|
|
340
|
+
res.json({
|
|
341
|
+
success: true,
|
|
342
|
+
data: { organization },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
res.status(400).json({
|
|
347
|
+
success: false,
|
|
348
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
/**
|
|
353
|
+
* GET /api/v1/organizations/:organizationId
|
|
354
|
+
* Get organization details
|
|
355
|
+
*/
|
|
356
|
+
app.get('/api/v1/organizations/:organizationId', authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
357
|
+
try {
|
|
358
|
+
const organization = await organizationService.getOrganizationById(req.params.organizationId);
|
|
359
|
+
if (!organization) {
|
|
360
|
+
return res.status(404).json({
|
|
361
|
+
success: false,
|
|
362
|
+
error: { code: 'NOT_FOUND', message: 'Organization not found' },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
// Get usage summary
|
|
366
|
+
const usage = await organizationService.getUsageSummary(req.params.organizationId);
|
|
367
|
+
res.json({
|
|
368
|
+
success: true,
|
|
369
|
+
data: { organization, usage },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
res.status(500).json({
|
|
374
|
+
success: false,
|
|
375
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
/**
|
|
380
|
+
* GET /api/v1/organizations/:organizationId/members
|
|
381
|
+
* Get organization members
|
|
382
|
+
*/
|
|
383
|
+
app.get('/api/v1/organizations/:organizationId/members', authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
const members = await organizationService.getOrganizationMembers(req.params.organizationId);
|
|
386
|
+
res.json({
|
|
387
|
+
success: true,
|
|
388
|
+
data: { members },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
res.status(500).json({
|
|
393
|
+
success: false,
|
|
394
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
/**
|
|
399
|
+
* POST /api/v1/organizations/:organizationId/members
|
|
400
|
+
* Add member to organization
|
|
401
|
+
*/
|
|
402
|
+
app.post('/api/v1/organizations/:organizationId/members', writeLimiter, authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
403
|
+
try {
|
|
404
|
+
// Check permission
|
|
405
|
+
const canInvite = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canInviteMembers');
|
|
406
|
+
if (!canInvite) {
|
|
407
|
+
return res.status(403).json({
|
|
408
|
+
success: false,
|
|
409
|
+
error: { code: 'FORBIDDEN', message: 'No permission to invite members' },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const { userId, role } = req.body;
|
|
413
|
+
const member = await organizationService.addMember({
|
|
414
|
+
organizationId: req.params.organizationId,
|
|
415
|
+
userId,
|
|
416
|
+
role: role || 'member',
|
|
417
|
+
invitedBy: req.user.id,
|
|
418
|
+
});
|
|
419
|
+
res.json({
|
|
420
|
+
success: true,
|
|
421
|
+
data: { member },
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
res.status(400).json({
|
|
426
|
+
success: false,
|
|
427
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
// ============================================================================
|
|
432
|
+
// TEAM ROUTES
|
|
433
|
+
// ============================================================================
|
|
434
|
+
/**
|
|
435
|
+
* POST /api/v1/organizations/:organizationId/teams
|
|
436
|
+
* Create a team
|
|
437
|
+
*/
|
|
438
|
+
app.post('/api/v1/organizations/:organizationId/teams', writeLimiter, authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
439
|
+
try {
|
|
440
|
+
const { name, slug, description } = req.body;
|
|
441
|
+
if (!name) {
|
|
442
|
+
return res.status(400).json({
|
|
443
|
+
success: false,
|
|
444
|
+
error: { code: 'INVALID_INPUT', message: 'Team name required' },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const team = await teamService.createTeam({
|
|
448
|
+
organizationId: req.params.organizationId,
|
|
449
|
+
name,
|
|
450
|
+
slug,
|
|
451
|
+
description,
|
|
452
|
+
}, req.user.id);
|
|
453
|
+
res.json({
|
|
454
|
+
success: true,
|
|
455
|
+
data: { team },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
res.status(400).json({
|
|
460
|
+
success: false,
|
|
461
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
/**
|
|
466
|
+
* GET /api/v1/organizations/:organizationId/teams
|
|
467
|
+
* Get organization teams
|
|
468
|
+
*/
|
|
469
|
+
app.get('/api/v1/organizations/:organizationId/teams', authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
470
|
+
try {
|
|
471
|
+
const teams = await teamService.getOrganizationTeams(req.params.organizationId);
|
|
472
|
+
res.json({
|
|
473
|
+
success: true,
|
|
474
|
+
data: { teams },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
res.status(500).json({
|
|
479
|
+
success: false,
|
|
480
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// SECRETS ROUTES
|
|
486
|
+
// ============================================================================
|
|
487
|
+
/**
|
|
488
|
+
* POST /api/v1/teams/:teamId/secrets
|
|
489
|
+
* Create a new secret
|
|
490
|
+
*/
|
|
491
|
+
app.post('/api/v1/teams/:teamId/secrets', writeLimiter, authenticateUser, async (req, res) => {
|
|
492
|
+
try {
|
|
493
|
+
const { environment, key, value, description, tags, rotationIntervalDays } = req.body;
|
|
494
|
+
if (!environment || !key || !value) {
|
|
495
|
+
return res.status(400).json({
|
|
496
|
+
success: false,
|
|
497
|
+
error: { code: 'INVALID_INPUT', message: 'Environment, key, and value required' },
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const secret = await secretsService.createSecret({
|
|
501
|
+
teamId: req.params.teamId,
|
|
502
|
+
environment,
|
|
503
|
+
key,
|
|
504
|
+
value,
|
|
505
|
+
description,
|
|
506
|
+
tags,
|
|
507
|
+
rotationIntervalDays,
|
|
508
|
+
createdBy: req.user.id,
|
|
509
|
+
});
|
|
510
|
+
res.json({
|
|
511
|
+
success: true,
|
|
512
|
+
data: { secret: { ...secret, encryptedValue: '***REDACTED***' } },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
const statusCode = error.message.includes('TIER_LIMIT_EXCEEDED') ? 402 : 400;
|
|
517
|
+
res.status(statusCode).json({
|
|
518
|
+
success: false,
|
|
519
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
/**
|
|
524
|
+
* GET /api/v1/teams/:teamId/secrets
|
|
525
|
+
* Get team secrets
|
|
526
|
+
*/
|
|
527
|
+
app.get('/api/v1/teams/:teamId/secrets', authenticateUser, async (req, res) => {
|
|
528
|
+
try {
|
|
529
|
+
const { environment, decrypt } = req.query;
|
|
530
|
+
const secrets = await secretsService.getTeamSecrets(req.params.teamId, environment, decrypt === 'true');
|
|
531
|
+
// Mask encrypted values unless decryption was requested
|
|
532
|
+
const maskedSecrets = secrets.map((secret) => ({
|
|
533
|
+
...secret,
|
|
534
|
+
encryptedValue: decrypt === 'true' ? secret.encryptedValue : '***REDACTED***',
|
|
535
|
+
}));
|
|
536
|
+
res.json({
|
|
537
|
+
success: true,
|
|
538
|
+
data: { secrets: maskedSecrets },
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
res.status(500).json({
|
|
543
|
+
success: false,
|
|
544
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
/**
|
|
549
|
+
* POST /api/v1/teams/:teamId/secrets/:secretId/retrieve
|
|
550
|
+
* Get secret by ID (POST method to prevent sensitive flags in GET logs)
|
|
551
|
+
* Request body: { decrypt: boolean }
|
|
552
|
+
*/
|
|
553
|
+
app.post('/api/v1/teams/:teamId/secrets/:secretId/retrieve', authenticateUser, async (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
// Use POST body for decrypt flag to prevent exposure in logs/URLs
|
|
556
|
+
const decrypt = req.body?.decrypt === true;
|
|
557
|
+
const secret = await secretsService.getSecretById(req.params.secretId, decrypt);
|
|
558
|
+
if (!secret) {
|
|
559
|
+
return res.status(404).json({
|
|
560
|
+
success: false,
|
|
561
|
+
error: { code: 'NOT_FOUND', message: 'Secret not found' },
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
res.json({
|
|
565
|
+
success: true,
|
|
566
|
+
data: {
|
|
567
|
+
secret: {
|
|
568
|
+
...secret,
|
|
569
|
+
encryptedValue: decrypt ? secret.encryptedValue : '***REDACTED***',
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
res.status(500).json({
|
|
576
|
+
success: false,
|
|
577
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* PUT /api/v1/teams/:teamId/secrets/:secretId
|
|
583
|
+
* Update secret
|
|
584
|
+
*/
|
|
585
|
+
app.put('/api/v1/teams/:teamId/secrets/:secretId', writeLimiter, authenticateUser, async (req, res) => {
|
|
586
|
+
try {
|
|
587
|
+
const { value, description, tags, rotationIntervalDays } = req.body;
|
|
588
|
+
const secret = await secretsService.updateSecret(req.params.secretId, {
|
|
589
|
+
value,
|
|
590
|
+
description,
|
|
591
|
+
tags,
|
|
592
|
+
rotationIntervalDays,
|
|
593
|
+
updatedBy: req.user.id,
|
|
594
|
+
});
|
|
595
|
+
res.json({
|
|
596
|
+
success: true,
|
|
597
|
+
data: { secret: { ...secret, encryptedValue: '***REDACTED***' } },
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
res.status(400).json({
|
|
602
|
+
success: false,
|
|
603
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
/**
|
|
608
|
+
* DELETE /api/v1/teams/:teamId/secrets/:secretId
|
|
609
|
+
* Delete secret
|
|
610
|
+
*/
|
|
611
|
+
app.delete('/api/v1/teams/:teamId/secrets/:secretId', writeLimiter, authenticateUser, async (req, res) => {
|
|
612
|
+
try {
|
|
613
|
+
await secretsService.deleteSecret(req.params.secretId, req.user.id);
|
|
614
|
+
res.json({
|
|
615
|
+
success: true,
|
|
616
|
+
data: { message: 'Secret deleted successfully' },
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
res.status(400).json({
|
|
621
|
+
success: false,
|
|
622
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
/**
|
|
627
|
+
* GET /api/v1/teams/:teamId/secrets/export/env
|
|
628
|
+
* Export secrets to .env format
|
|
629
|
+
*/
|
|
630
|
+
app.get('/api/v1/teams/:teamId/secrets/export/env', authenticateUser, async (req, res) => {
|
|
631
|
+
try {
|
|
632
|
+
const { environment } = req.query;
|
|
633
|
+
if (!environment) {
|
|
634
|
+
return res.status(400).json({
|
|
635
|
+
success: false,
|
|
636
|
+
error: { code: 'INVALID_INPUT', message: 'Environment parameter required' },
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const envContent = await secretsService.exportToEnv(req.params.teamId, environment);
|
|
640
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
641
|
+
res.setHeader('Content-Disposition', `attachment; filename="${environment}.env"`);
|
|
642
|
+
res.send(envContent);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
res.status(500).json({
|
|
646
|
+
success: false,
|
|
647
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
/**
|
|
652
|
+
* POST /api/v1/teams/:teamId/secrets/import/env
|
|
653
|
+
* Import secrets from .env format
|
|
654
|
+
*/
|
|
655
|
+
app.post('/api/v1/teams/:teamId/secrets/import/env', writeLimiter, authenticateUser, async (req, res) => {
|
|
656
|
+
try {
|
|
657
|
+
const { environment, content } = req.body;
|
|
658
|
+
if (!environment || !content) {
|
|
659
|
+
return res.status(400).json({
|
|
660
|
+
success: false,
|
|
661
|
+
error: { code: 'INVALID_INPUT', message: 'Environment and content required' },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const result = await secretsService.importFromEnv(req.params.teamId, environment, content, req.user.id);
|
|
665
|
+
res.json({
|
|
666
|
+
success: true,
|
|
667
|
+
data: result,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
res.status(400).json({
|
|
672
|
+
success: false,
|
|
673
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
// ============================================================================
|
|
678
|
+
// AUDIT LOG ROUTES
|
|
679
|
+
// ============================================================================
|
|
680
|
+
/**
|
|
681
|
+
* GET /api/v1/organizations/:organizationId/audit-logs
|
|
682
|
+
* Get organization audit logs
|
|
683
|
+
*/
|
|
684
|
+
app.get('/api/v1/organizations/:organizationId/audit-logs', authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
685
|
+
try {
|
|
686
|
+
// Check permission
|
|
687
|
+
const canView = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canViewAuditLogs');
|
|
688
|
+
if (!canView) {
|
|
689
|
+
return res.status(403).json({
|
|
690
|
+
success: false,
|
|
691
|
+
error: { code: 'FORBIDDEN', message: 'No permission to view audit logs' },
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const { limit = 50, offset = 0, action, userId, teamId, startDate, endDate } = req.query;
|
|
695
|
+
const result = await auditLogger.getOrganizationLogs(req.params.organizationId, {
|
|
696
|
+
limit: parseInt(limit),
|
|
697
|
+
offset: parseInt(offset),
|
|
698
|
+
action: action,
|
|
699
|
+
userId: userId,
|
|
700
|
+
teamId: teamId,
|
|
701
|
+
startDate: startDate ? new Date(startDate) : undefined,
|
|
702
|
+
endDate: endDate ? new Date(endDate) : undefined,
|
|
703
|
+
});
|
|
704
|
+
res.json({
|
|
705
|
+
success: true,
|
|
706
|
+
data: result,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
res.status(500).json({
|
|
711
|
+
success: false,
|
|
712
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
// ============================================================================
|
|
717
|
+
// BILLING ROUTES
|
|
718
|
+
// ============================================================================
|
|
719
|
+
/**
|
|
720
|
+
* POST /api/v1/organizations/:organizationId/billing/checkout
|
|
721
|
+
* Create Stripe checkout session
|
|
722
|
+
*/
|
|
723
|
+
app.post('/api/v1/organizations/:organizationId/billing/checkout', writeLimiter, authenticateUser, requireOrganizationMembership, async (req, res) => {
|
|
724
|
+
try {
|
|
725
|
+
// Check permission
|
|
726
|
+
const canManage = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canManageBilling');
|
|
727
|
+
if (!canManage) {
|
|
728
|
+
return res.status(403).json({
|
|
729
|
+
success: false,
|
|
730
|
+
error: { code: 'FORBIDDEN', message: 'No permission to manage billing' },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
const { tier, billingPeriod, successUrl, cancelUrl } = req.body;
|
|
734
|
+
const org = await organizationService.getOrganizationById(req.params.organizationId);
|
|
735
|
+
if (!org) {
|
|
736
|
+
return res.status(404).json({
|
|
737
|
+
success: false,
|
|
738
|
+
error: { code: 'NOT_FOUND', message: 'Organization not found' },
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const checkout = await billingService.createCheckoutSession({
|
|
742
|
+
organizationId: req.params.organizationId,
|
|
743
|
+
tier,
|
|
744
|
+
billingPeriod: billingPeriod || 'monthly',
|
|
745
|
+
successUrl,
|
|
746
|
+
cancelUrl,
|
|
747
|
+
customerId: org.stripeCustomerId || undefined,
|
|
748
|
+
});
|
|
749
|
+
res.json({
|
|
750
|
+
success: true,
|
|
751
|
+
data: checkout,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
res.status(400).json({
|
|
756
|
+
success: false,
|
|
757
|
+
error: { code: 'INTERNAL_ERROR', message: error.message },
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
/**
|
|
762
|
+
* POST /api/v1/billing/webhooks
|
|
763
|
+
* Handle Stripe webhooks
|
|
764
|
+
*/
|
|
765
|
+
app.post('/api/v1/billing/webhooks', async (req, res) => {
|
|
766
|
+
try {
|
|
767
|
+
const signature = req.headers['stripe-signature'];
|
|
768
|
+
const payload = JSON.stringify(req.body);
|
|
769
|
+
await billingService.handleWebhook(payload, signature);
|
|
770
|
+
res.json({ received: true });
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
console.error('Webhook error:', error);
|
|
774
|
+
res.status(400).json({ error: error.message });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
console.log('✅ SaaS API routes registered');
|
|
778
|
+
}
|