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.
@@ -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
+ }