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,592 @@
1
+ /**
2
+ * LSH SaaS Organization & Team Management Service
3
+ * Handles organizations, teams, members, and RBAC
4
+ */
5
+ import { getSupabaseClient } from './supabase-client.js';
6
+ import { auditLogger } from './saas-audit.js';
7
+ /**
8
+ * Generate URL-friendly slug from name
9
+ */
10
+ function slugify(name) {
11
+ let result = name
12
+ .toLowerCase()
13
+ .trim()
14
+ .replace(/[^\w\s-]/g, '')
15
+ .replace(/[\s_-]+/g, '-');
16
+ // Remove leading and trailing dashes without vulnerable regex
17
+ while (result.startsWith('-')) {
18
+ result = result.slice(1);
19
+ }
20
+ while (result.endsWith('-')) {
21
+ result = result.slice(0, -1);
22
+ }
23
+ return result;
24
+ }
25
+ /**
26
+ * Organization Service
27
+ */
28
+ export class OrganizationService {
29
+ supabase = getSupabaseClient();
30
+ /**
31
+ * Create a new organization
32
+ */
33
+ async createOrganization(input) {
34
+ const slug = input.slug || slugify(input.name);
35
+ // Check if slug already exists
36
+ const { data: existing } = await this.supabase
37
+ .from('organizations')
38
+ .select('id')
39
+ .eq('slug', slug)
40
+ .single();
41
+ if (existing) {
42
+ throw new Error('ALREADY_EXISTS: Organization slug already taken');
43
+ }
44
+ // Create organization
45
+ const { data: org, error } = await this.supabase
46
+ .from('organizations')
47
+ .insert({
48
+ name: input.name,
49
+ slug,
50
+ subscription_tier: 'free',
51
+ subscription_status: 'active',
52
+ })
53
+ .select()
54
+ .single();
55
+ if (error) {
56
+ throw new Error(`Failed to create organization: ${error.message}`);
57
+ }
58
+ // Add owner as first member
59
+ await this.addMember({
60
+ organizationId: org.id,
61
+ userId: input.ownerId,
62
+ role: 'owner',
63
+ });
64
+ // Log audit event
65
+ await auditLogger.log({
66
+ organizationId: org.id,
67
+ userId: input.ownerId,
68
+ action: 'organization.create',
69
+ resourceType: 'organization',
70
+ resourceId: org.id,
71
+ newValue: { name: org.name, slug: org.slug },
72
+ });
73
+ return this.mapDbOrgToOrg(org);
74
+ }
75
+ /**
76
+ * Get organization by ID
77
+ */
78
+ async getOrganizationById(id) {
79
+ const { data: org, error } = await this.supabase
80
+ .from('organizations')
81
+ .select('*')
82
+ .eq('id', id)
83
+ .is('deleted_at', null)
84
+ .single();
85
+ if (error || !org) {
86
+ return null;
87
+ }
88
+ return this.mapDbOrgToOrg(org);
89
+ }
90
+ /**
91
+ * Get organization by slug
92
+ */
93
+ async getOrganizationBySlug(slug) {
94
+ const { data: org, error } = await this.supabase
95
+ .from('organizations')
96
+ .select('*')
97
+ .eq('slug', slug)
98
+ .is('deleted_at', null)
99
+ .single();
100
+ if (error || !org) {
101
+ return null;
102
+ }
103
+ return this.mapDbOrgToOrg(org);
104
+ }
105
+ /**
106
+ * Update organization
107
+ */
108
+ async updateOrganization(id, updates) {
109
+ const updateData = {};
110
+ if (updates.name) {
111
+ updateData.name = updates.name;
112
+ }
113
+ if (updates.settings) {
114
+ updateData.settings = updates.settings;
115
+ }
116
+ const { data: org, error } = await this.supabase
117
+ .from('organizations')
118
+ .update(updateData)
119
+ .eq('id', id)
120
+ .select()
121
+ .single();
122
+ if (error) {
123
+ throw new Error(`Failed to update organization: ${error.message}`);
124
+ }
125
+ return this.mapDbOrgToOrg(org);
126
+ }
127
+ /**
128
+ * Delete organization (soft delete)
129
+ */
130
+ async deleteOrganization(id, deletedBy) {
131
+ const { error } = await this.supabase
132
+ .from('organizations')
133
+ .update({ deleted_at: new Date().toISOString() })
134
+ .eq('id', id);
135
+ if (error) {
136
+ throw new Error(`Failed to delete organization: ${error.message}`);
137
+ }
138
+ await auditLogger.log({
139
+ organizationId: id,
140
+ userId: deletedBy,
141
+ action: 'organization.delete',
142
+ resourceType: 'organization',
143
+ resourceId: id,
144
+ });
145
+ }
146
+ /**
147
+ * Add member to organization
148
+ */
149
+ async addMember(params) {
150
+ // Check if already a member
151
+ const { data: existing } = await this.supabase
152
+ .from('organization_members')
153
+ .select('id')
154
+ .eq('organization_id', params.organizationId)
155
+ .eq('user_id', params.userId)
156
+ .single();
157
+ if (existing) {
158
+ throw new Error('ALREADY_EXISTS: User is already a member');
159
+ }
160
+ // Check tier limits
161
+ await this.checkMemberLimit(params.organizationId);
162
+ const { data: member, error } = await this.supabase
163
+ .from('organization_members')
164
+ .insert({
165
+ organization_id: params.organizationId,
166
+ user_id: params.userId,
167
+ role: params.role,
168
+ invited_by: params.invitedBy || null,
169
+ accepted_at: new Date().toISOString(),
170
+ })
171
+ .select()
172
+ .single();
173
+ if (error) {
174
+ throw new Error(`Failed to add member: ${error.message}`);
175
+ }
176
+ await auditLogger.log({
177
+ organizationId: params.organizationId,
178
+ userId: params.invitedBy || params.userId,
179
+ action: 'member.accept',
180
+ resourceType: 'user',
181
+ resourceId: params.userId,
182
+ newValue: { role: params.role },
183
+ });
184
+ return this.mapDbMemberToMember(member);
185
+ }
186
+ /**
187
+ * Update member role
188
+ */
189
+ async updateMemberRole(organizationId, userId, newRole, updatedBy) {
190
+ const { data: member, error } = await this.supabase
191
+ .from('organization_members')
192
+ .update({ role: newRole })
193
+ .eq('organization_id', organizationId)
194
+ .eq('user_id', userId)
195
+ .select()
196
+ .single();
197
+ if (error) {
198
+ throw new Error(`Failed to update member role: ${error.message}`);
199
+ }
200
+ await auditLogger.log({
201
+ organizationId,
202
+ userId: updatedBy,
203
+ action: 'member.role_change',
204
+ resourceType: 'user',
205
+ resourceId: userId,
206
+ newValue: { role: newRole },
207
+ });
208
+ return this.mapDbMemberToMember(member);
209
+ }
210
+ /**
211
+ * Remove member from organization
212
+ */
213
+ async removeMember(organizationId, userId, removedBy) {
214
+ const { error } = await this.supabase
215
+ .from('organization_members')
216
+ .delete()
217
+ .eq('organization_id', organizationId)
218
+ .eq('user_id', userId);
219
+ if (error) {
220
+ throw new Error(`Failed to remove member: ${error.message}`);
221
+ }
222
+ await auditLogger.log({
223
+ organizationId,
224
+ userId: removedBy,
225
+ action: 'member.remove',
226
+ resourceType: 'user',
227
+ resourceId: userId,
228
+ });
229
+ }
230
+ /**
231
+ * Get organization members
232
+ */
233
+ async getOrganizationMembers(organizationId) {
234
+ const { data, error } = await this.supabase
235
+ .from('organization_members_detailed')
236
+ .select('*')
237
+ .eq('organization_id', organizationId);
238
+ if (error) {
239
+ throw new Error(`Failed to get members: ${error.message}`);
240
+ }
241
+ return (data || []).map(this.mapDbMemberDetailedToMemberDetailed);
242
+ }
243
+ /**
244
+ * Get user's role in organization
245
+ */
246
+ async getUserRole(organizationId, userId) {
247
+ const { data, error } = await this.supabase
248
+ .from('organization_members')
249
+ .select('role')
250
+ .eq('organization_id', organizationId)
251
+ .eq('user_id', userId)
252
+ .single();
253
+ if (error || !data) {
254
+ return null;
255
+ }
256
+ return data.role;
257
+ }
258
+ /**
259
+ * Check if user has permission
260
+ */
261
+ async hasPermission(organizationId, userId, permission) {
262
+ const role = await this.getUserRole(organizationId, userId);
263
+ if (!role)
264
+ return false;
265
+ const { ROLE_PERMISSIONS } = await import('./saas-types.js');
266
+ return ROLE_PERMISSIONS[role][permission];
267
+ }
268
+ /**
269
+ * Get organization usage summary
270
+ */
271
+ async getUsageSummary(organizationId) {
272
+ const { data, error } = await this.supabase
273
+ .from('organization_usage_summary')
274
+ .select('*')
275
+ .eq('organization_id', organizationId)
276
+ .single();
277
+ if (error) {
278
+ throw new Error(`Failed to get usage summary: ${error.message}`);
279
+ }
280
+ return {
281
+ organizationId: data.organization_id,
282
+ name: data.name,
283
+ slug: data.slug,
284
+ subscriptionTier: data.subscription_tier,
285
+ memberCount: data.member_count || 0,
286
+ teamCount: data.team_count || 0,
287
+ secretCount: data.secret_count || 0,
288
+ environmentCount: data.environment_count || 0,
289
+ };
290
+ }
291
+ /**
292
+ * Check if organization is within tier limits
293
+ */
294
+ async checkTierLimits(organizationId) {
295
+ const org = await this.getOrganizationById(organizationId);
296
+ if (!org) {
297
+ throw new Error('NOT_FOUND: Organization not found');
298
+ }
299
+ const usage = await this.getUsageSummary(organizationId);
300
+ const { TIER_LIMITS } = await import('./saas-types.js');
301
+ const limits = TIER_LIMITS[org.subscriptionTier];
302
+ const violations = [];
303
+ if (usage.memberCount > limits.teamMembers) {
304
+ violations.push(`Team member limit exceeded (${usage.memberCount}/${limits.teamMembers})`);
305
+ }
306
+ if (usage.secretCount > limits.secrets) {
307
+ violations.push(`Secret limit exceeded (${usage.secretCount}/${limits.secrets})`);
308
+ }
309
+ if (usage.environmentCount > limits.environments) {
310
+ violations.push(`Environment limit exceeded (${usage.environmentCount}/${limits.environments})`);
311
+ }
312
+ return {
313
+ withinLimits: violations.length === 0,
314
+ violations,
315
+ };
316
+ }
317
+ /**
318
+ * Check member limit before adding
319
+ */
320
+ async checkMemberLimit(organizationId) {
321
+ const usage = await this.getUsageSummary(organizationId);
322
+ const org = await this.getOrganizationById(organizationId);
323
+ if (!org) {
324
+ throw new Error('NOT_FOUND: Organization not found');
325
+ }
326
+ const { TIER_LIMITS } = await import('./saas-types.js');
327
+ const limits = TIER_LIMITS[org.subscriptionTier];
328
+ if (usage.memberCount >= limits.teamMembers) {
329
+ throw new Error('TIER_LIMIT_EXCEEDED: Team member limit reached. Please upgrade your plan.');
330
+ }
331
+ }
332
+ /**
333
+ * Map database org to Organization type
334
+ */
335
+ mapDbOrgToOrg(dbOrg) {
336
+ return {
337
+ id: dbOrg.id,
338
+ name: dbOrg.name,
339
+ slug: dbOrg.slug,
340
+ createdAt: new Date(dbOrg.created_at),
341
+ updatedAt: new Date(dbOrg.updated_at),
342
+ stripeCustomerId: dbOrg.stripe_customer_id,
343
+ subscriptionTier: dbOrg.subscription_tier,
344
+ subscriptionStatus: dbOrg.subscription_status,
345
+ subscriptionExpiresAt: dbOrg.subscription_expires_at
346
+ ? new Date(dbOrg.subscription_expires_at)
347
+ : null,
348
+ settings: dbOrg.settings || {},
349
+ deletedAt: dbOrg.deleted_at ? new Date(dbOrg.deleted_at) : null,
350
+ };
351
+ }
352
+ /**
353
+ * Map database member to OrganizationMember type
354
+ */
355
+ mapDbMemberToMember(dbMember) {
356
+ return {
357
+ id: dbMember.id,
358
+ organizationId: dbMember.organization_id,
359
+ userId: dbMember.user_id,
360
+ role: dbMember.role,
361
+ invitedBy: dbMember.invited_by,
362
+ invitedAt: new Date(dbMember.invited_at),
363
+ acceptedAt: dbMember.accepted_at ? new Date(dbMember.accepted_at) : null,
364
+ createdAt: new Date(dbMember.created_at),
365
+ updatedAt: new Date(dbMember.updated_at),
366
+ };
367
+ }
368
+ /**
369
+ * Map database member detailed to OrganizationMemberDetailed type
370
+ */
371
+ mapDbMemberDetailedToMemberDetailed(dbMember) {
372
+ return {
373
+ id: dbMember.id,
374
+ organizationId: dbMember.organization_id,
375
+ userId: dbMember.user_id,
376
+ role: dbMember.role,
377
+ invitedBy: dbMember.invited_by,
378
+ invitedAt: new Date(dbMember.invited_at),
379
+ acceptedAt: dbMember.accepted_at ? new Date(dbMember.accepted_at) : null,
380
+ createdAt: new Date(dbMember.created_at),
381
+ updatedAt: new Date(dbMember.updated_at),
382
+ email: dbMember.email,
383
+ firstName: dbMember.first_name,
384
+ lastName: dbMember.last_name,
385
+ avatarUrl: dbMember.avatar_url,
386
+ lastLoginAt: dbMember.last_login_at ? new Date(dbMember.last_login_at) : null,
387
+ organizationName: dbMember.organization_name,
388
+ organizationSlug: dbMember.organization_slug,
389
+ };
390
+ }
391
+ }
392
+ /**
393
+ * Team Service
394
+ */
395
+ export class TeamService {
396
+ supabase = getSupabaseClient();
397
+ /**
398
+ * Create a new team
399
+ */
400
+ async createTeam(input, createdBy) {
401
+ const slug = input.slug || slugify(input.name);
402
+ // Check if slug already exists in this org
403
+ const { data: existing } = await this.supabase
404
+ .from('teams')
405
+ .select('id')
406
+ .eq('organization_id', input.organizationId)
407
+ .eq('slug', slug)
408
+ .single();
409
+ if (existing) {
410
+ throw new Error('ALREADY_EXISTS: Team slug already taken in this organization');
411
+ }
412
+ const { data: team, error } = await this.supabase
413
+ .from('teams')
414
+ .insert({
415
+ organization_id: input.organizationId,
416
+ name: input.name,
417
+ slug,
418
+ description: input.description || null,
419
+ })
420
+ .select()
421
+ .single();
422
+ if (error) {
423
+ throw new Error(`Failed to create team: ${error.message}`);
424
+ }
425
+ await auditLogger.log({
426
+ organizationId: input.organizationId,
427
+ teamId: team.id,
428
+ userId: createdBy,
429
+ action: 'team.create',
430
+ resourceType: 'team',
431
+ resourceId: team.id,
432
+ newValue: { name: team.name, slug: team.slug },
433
+ });
434
+ return this.mapDbTeamToTeam(team);
435
+ }
436
+ /**
437
+ * Get team by ID
438
+ */
439
+ async getTeamById(id) {
440
+ const { data: team, error } = await this.supabase
441
+ .from('teams')
442
+ .select('*')
443
+ .eq('id', id)
444
+ .is('deleted_at', null)
445
+ .single();
446
+ if (error || !team) {
447
+ return null;
448
+ }
449
+ return this.mapDbTeamToTeam(team);
450
+ }
451
+ /**
452
+ * Get teams for organization
453
+ */
454
+ async getOrganizationTeams(organizationId) {
455
+ const { data: teams, error } = await this.supabase
456
+ .from('teams')
457
+ .select('*')
458
+ .eq('organization_id', organizationId)
459
+ .is('deleted_at', null)
460
+ .order('created_at', { ascending: false });
461
+ if (error) {
462
+ throw new Error(`Failed to get teams: ${error.message}`);
463
+ }
464
+ return (teams || []).map(this.mapDbTeamToTeam);
465
+ }
466
+ /**
467
+ * Update team
468
+ */
469
+ async updateTeam(id, updates, updatedBy) {
470
+ const { data: team, error } = await this.supabase
471
+ .from('teams')
472
+ .update(updates)
473
+ .eq('id', id)
474
+ .select()
475
+ .single();
476
+ if (error) {
477
+ throw new Error(`Failed to update team: ${error.message}`);
478
+ }
479
+ await auditLogger.log({
480
+ organizationId: team.organization_id,
481
+ teamId: id,
482
+ userId: updatedBy,
483
+ action: 'team.update',
484
+ resourceType: 'team',
485
+ resourceId: id,
486
+ newValue: updates,
487
+ });
488
+ return this.mapDbTeamToTeam(team);
489
+ }
490
+ /**
491
+ * Delete team (soft delete)
492
+ */
493
+ async deleteTeam(id, deletedBy) {
494
+ const team = await this.getTeamById(id);
495
+ if (!team) {
496
+ throw new Error('NOT_FOUND: Team not found');
497
+ }
498
+ const { error } = await this.supabase
499
+ .from('teams')
500
+ .update({ deleted_at: new Date().toISOString() })
501
+ .eq('id', id);
502
+ if (error) {
503
+ throw new Error(`Failed to delete team: ${error.message}`);
504
+ }
505
+ await auditLogger.log({
506
+ organizationId: team.organizationId,
507
+ teamId: id,
508
+ userId: deletedBy,
509
+ action: 'team.delete',
510
+ resourceType: 'team',
511
+ resourceId: id,
512
+ });
513
+ }
514
+ /**
515
+ * Add member to team
516
+ */
517
+ async addTeamMember(teamId, userId, role = 'member') {
518
+ const { data: member, error } = await this.supabase
519
+ .from('team_members')
520
+ .insert({
521
+ team_id: teamId,
522
+ user_id: userId,
523
+ role,
524
+ })
525
+ .select()
526
+ .single();
527
+ if (error) {
528
+ throw new Error(`Failed to add team member: ${error.message}`);
529
+ }
530
+ return this.mapDbTeamMemberToTeamMember(member);
531
+ }
532
+ /**
533
+ * Remove member from team
534
+ */
535
+ async removeTeamMember(teamId, userId) {
536
+ const { error } = await this.supabase
537
+ .from('team_members')
538
+ .delete()
539
+ .eq('team_id', teamId)
540
+ .eq('user_id', userId);
541
+ if (error) {
542
+ throw new Error(`Failed to remove team member: ${error.message}`);
543
+ }
544
+ }
545
+ /**
546
+ * Get team members
547
+ */
548
+ async getTeamMembers(teamId) {
549
+ const { data, error } = await this.supabase
550
+ .from('team_members_detailed')
551
+ .select('*')
552
+ .eq('team_id', teamId);
553
+ if (error) {
554
+ throw new Error(`Failed to get team members: ${error.message}`);
555
+ }
556
+ return data || [];
557
+ }
558
+ /**
559
+ * Map database team to Team type
560
+ */
561
+ mapDbTeamToTeam(dbTeam) {
562
+ return {
563
+ id: dbTeam.id,
564
+ organizationId: dbTeam.organization_id,
565
+ name: dbTeam.name,
566
+ slug: dbTeam.slug,
567
+ description: dbTeam.description,
568
+ encryptionKeyId: dbTeam.encryption_key_id,
569
+ createdAt: new Date(dbTeam.created_at),
570
+ updatedAt: new Date(dbTeam.updated_at),
571
+ deletedAt: dbTeam.deleted_at ? new Date(dbTeam.deleted_at) : null,
572
+ };
573
+ }
574
+ /**
575
+ * Map database team member to TeamMember type
576
+ */
577
+ mapDbTeamMemberToTeamMember(dbMember) {
578
+ return {
579
+ id: dbMember.id,
580
+ teamId: dbMember.team_id,
581
+ userId: dbMember.user_id,
582
+ role: dbMember.role,
583
+ createdAt: new Date(dbMember.created_at),
584
+ updatedAt: new Date(dbMember.updated_at),
585
+ };
586
+ }
587
+ }
588
+ /**
589
+ * Singleton instances
590
+ */
591
+ export const organizationService = new OrganizationService();
592
+ export const teamService = new TeamService();