nobalmako 1.0.0

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.
Files changed (123) hide show
  1. package/README.md +112 -0
  2. package/components.json +22 -0
  3. package/dist/nobalmako.js +272 -0
  4. package/drizzle/0000_pink_spiral.sql +126 -0
  5. package/drizzle/meta/0000_snapshot.json +1027 -0
  6. package/drizzle/meta/_journal.json +13 -0
  7. package/drizzle.config.ts +10 -0
  8. package/eslint.config.mjs +18 -0
  9. package/next.config.ts +7 -0
  10. package/package.json +80 -0
  11. package/postcss.config.mjs +7 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/server/index.ts +118 -0
  18. package/src/app/api/api-keys/[id]/route.ts +147 -0
  19. package/src/app/api/api-keys/route.ts +151 -0
  20. package/src/app/api/audit-logs/route.ts +84 -0
  21. package/src/app/api/auth/forgot-password/route.ts +47 -0
  22. package/src/app/api/auth/login/route.ts +99 -0
  23. package/src/app/api/auth/logout/route.ts +15 -0
  24. package/src/app/api/auth/me/route.ts +23 -0
  25. package/src/app/api/auth/mfa/setup/route.ts +33 -0
  26. package/src/app/api/auth/mfa/verify/route.ts +45 -0
  27. package/src/app/api/auth/register/route.ts +140 -0
  28. package/src/app/api/auth/reset-password/route.ts +52 -0
  29. package/src/app/api/auth/update/route.ts +71 -0
  30. package/src/app/api/auth/verify/route.ts +39 -0
  31. package/src/app/api/environments/route.ts +227 -0
  32. package/src/app/api/team-members/route.ts +385 -0
  33. package/src/app/api/teams/route.ts +217 -0
  34. package/src/app/api/variable-history/route.ts +218 -0
  35. package/src/app/api/variables/route.ts +476 -0
  36. package/src/app/api/webhooks/route.ts +77 -0
  37. package/src/app/api-keys/APIKeysClient.tsx +316 -0
  38. package/src/app/api-keys/page.tsx +10 -0
  39. package/src/app/api-reference/page.tsx +324 -0
  40. package/src/app/audit-log/AuditLogClient.tsx +229 -0
  41. package/src/app/audit-log/page.tsx +10 -0
  42. package/src/app/auth/forgot-password/page.tsx +121 -0
  43. package/src/app/auth/login/LoginForm.tsx +145 -0
  44. package/src/app/auth/login/page.tsx +11 -0
  45. package/src/app/auth/register/RegisterForm.tsx +156 -0
  46. package/src/app/auth/register/page.tsx +16 -0
  47. package/src/app/auth/reset-password/page.tsx +160 -0
  48. package/src/app/dashboard/DashboardClient.tsx +219 -0
  49. package/src/app/dashboard/page.tsx +11 -0
  50. package/src/app/docs/page.tsx +251 -0
  51. package/src/app/favicon.ico +0 -0
  52. package/src/app/globals.css +123 -0
  53. package/src/app/layout.tsx +35 -0
  54. package/src/app/page.tsx +231 -0
  55. package/src/app/profile/ProfileClient.tsx +230 -0
  56. package/src/app/profile/page.tsx +10 -0
  57. package/src/app/project/[id]/ProjectDetailsClient.tsx +512 -0
  58. package/src/app/project/[id]/page.tsx +17 -0
  59. package/src/bin/nobalmako.ts +341 -0
  60. package/src/components/ApiKeysManager.tsx +529 -0
  61. package/src/components/AppLayout.tsx +193 -0
  62. package/src/components/BulkActions.tsx +138 -0
  63. package/src/components/CreateEnvironmentDialog.tsx +207 -0
  64. package/src/components/CreateTeamDialog.tsx +174 -0
  65. package/src/components/CreateVariableDialog.tsx +311 -0
  66. package/src/components/DeleteEnvironmentDialog.tsx +104 -0
  67. package/src/components/DeleteTeamDialog.tsx +112 -0
  68. package/src/components/DeleteVariableDialog.tsx +103 -0
  69. package/src/components/EditEnvironmentDialog.tsx +202 -0
  70. package/src/components/EditMemberDialog.tsx +143 -0
  71. package/src/components/EditTeamDialog.tsx +178 -0
  72. package/src/components/EditVariableDialog.tsx +231 -0
  73. package/src/components/ImportVariablesDialog.tsx +347 -0
  74. package/src/components/InviteMemberDialog.tsx +191 -0
  75. package/src/components/LeaveProjectDialog.tsx +111 -0
  76. package/src/components/MFASettings.tsx +136 -0
  77. package/src/components/ProjectDiff.tsx +123 -0
  78. package/src/components/Providers.tsx +24 -0
  79. package/src/components/RemoveMemberDialog.tsx +112 -0
  80. package/src/components/SearchDialog.tsx +276 -0
  81. package/src/components/SecurityOverview.tsx +92 -0
  82. package/src/components/TeamMembersManager.tsx +103 -0
  83. package/src/components/VariableHistoryDialog.tsx +265 -0
  84. package/src/components/WebhooksManager.tsx +169 -0
  85. package/src/components/ui/alert-dialog.tsx +160 -0
  86. package/src/components/ui/alert.tsx +59 -0
  87. package/src/components/ui/avatar.tsx +53 -0
  88. package/src/components/ui/badge.tsx +46 -0
  89. package/src/components/ui/button.tsx +62 -0
  90. package/src/components/ui/card.tsx +92 -0
  91. package/src/components/ui/checkbox.tsx +32 -0
  92. package/src/components/ui/dialog.tsx +143 -0
  93. package/src/components/ui/dropdown-menu.tsx +257 -0
  94. package/src/components/ui/input.tsx +21 -0
  95. package/src/components/ui/label.tsx +24 -0
  96. package/src/components/ui/select.tsx +190 -0
  97. package/src/components/ui/separator.tsx +28 -0
  98. package/src/components/ui/sonner.tsx +37 -0
  99. package/src/components/ui/switch.tsx +31 -0
  100. package/src/components/ui/table.tsx +117 -0
  101. package/src/components/ui/tabs.tsx +66 -0
  102. package/src/components/ui/textarea.tsx +18 -0
  103. package/src/hooks/use-api-keys.ts +95 -0
  104. package/src/hooks/use-audit-logs.ts +58 -0
  105. package/src/hooks/use-auth.tsx +121 -0
  106. package/src/hooks/use-environments.ts +33 -0
  107. package/src/hooks/use-project-permissions.ts +49 -0
  108. package/src/hooks/use-team-members.ts +30 -0
  109. package/src/hooks/use-teams.ts +33 -0
  110. package/src/hooks/use-variables.ts +38 -0
  111. package/src/lib/audit.ts +36 -0
  112. package/src/lib/auth.ts +108 -0
  113. package/src/lib/crypto.ts +39 -0
  114. package/src/lib/db.ts +15 -0
  115. package/src/lib/dynamic-providers.ts +19 -0
  116. package/src/lib/email.ts +110 -0
  117. package/src/lib/mail.ts +51 -0
  118. package/src/lib/permissions.ts +51 -0
  119. package/src/lib/schema.ts +240 -0
  120. package/src/lib/seed.ts +107 -0
  121. package/src/lib/utils.ts +6 -0
  122. package/src/lib/webhooks.ts +42 -0
  123. package/tsconfig.json +34 -0
@@ -0,0 +1,108 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { cookies, headers } from 'next/headers';
3
+ import { db } from '@/lib/db';
4
+ import { users, apiKeys } from '@/lib/schema';
5
+ import { eq } from 'drizzle-orm';
6
+ import bcrypt from 'bcryptjs';
7
+
8
+ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
9
+
10
+ export interface AuthUser {
11
+ id: string;
12
+ name: string;
13
+ email: string;
14
+ avatar?: string | null;
15
+ }
16
+
17
+ export async function getUserFromToken(): Promise<AuthUser | null> {
18
+ try {
19
+ const headerList = await headers();
20
+ const authHeader = headerList.get('Authorization');
21
+
22
+ // Check for API Key first (Bearer nm_...)
23
+ if (authHeader && authHeader.startsWith('Bearer nm_')) {
24
+ const apiKeyString = authHeader.replace('Bearer ', '');
25
+
26
+ // In a real production app with many keys, we'd use a faster lookup (like a prefix or hint)
27
+ // but for this implementation we'll fetch all keys and verify (or use a better strategy if possible)
28
+ // Since we hash with bcrypt, we can't search by index.
29
+ // Optimization: we could store a non-hashed hint/prefix to narrow it down.
30
+ // But for now, we'll fetch all and check.
31
+ const allKeys = await db.select().from(apiKeys);
32
+
33
+ for (const keyRecord of allKeys) {
34
+ const isValid = await bcrypt.compare(apiKeyString, keyRecord.keyHash);
35
+ if (isValid) {
36
+ // Verify expiry
37
+ if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
38
+ continue;
39
+ }
40
+
41
+ // IP Whitelisting
42
+ const userIp = headerList.get('x-forwarded-for')?.split(',')[0] || headerList.get('x-real-ip');
43
+ if (keyRecord.allowedIps && keyRecord.allowedIps.length > 0) {
44
+ if (!userIp || !keyRecord.allowedIps.includes(userIp)) {
45
+ console.warn(`Blocked request from ${userIp} for API key ${keyRecord.id}`);
46
+ continue;
47
+ }
48
+ }
49
+
50
+ // Fetch the user
51
+ const [user] = await db
52
+ .select({
53
+ id: users.id,
54
+ name: users.name,
55
+ email: users.email,
56
+ avatar: users.avatar,
57
+ })
58
+ .from(users)
59
+ .where(eq(users.id, keyRecord.userId))
60
+ .limit(1);
61
+
62
+ if (user) {
63
+ // Update last used
64
+ await db.update(apiKeys)
65
+ .set({ lastUsed: new Date() })
66
+ .where(eq(apiKeys.id, keyRecord.id));
67
+
68
+ return user;
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // Fallback to cookie-based JWT
75
+ const cookieStore = await cookies();
76
+ const token = cookieStore.get('auth-token')?.value;
77
+
78
+ if (!token) {
79
+ return null;
80
+ }
81
+
82
+ const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string };
83
+
84
+ const [user] = await db
85
+ .select({
86
+ id: users.id,
87
+ name: users.name,
88
+ email: users.email,
89
+ avatar: users.avatar,
90
+ })
91
+ .from(users)
92
+ .where(eq(users.id, decoded.userId))
93
+ .limit(1);
94
+
95
+ return user || null;
96
+ } catch (error) {
97
+ console.error('Token verification error:', error);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export function verifyToken(token: string): { userId: number; email: string } | null {
103
+ try {
104
+ return jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
105
+ } catch (_error) {
106
+ return null;
107
+ }
108
+ }
@@ -0,0 +1,39 @@
1
+ import crypto from 'crypto';
2
+
3
+ const ALGORITHM = 'aes-256-cbc';
4
+ const IV_LENGTH = 16; // For AES, this is always 16
5
+
6
+ function getEncryptionKey() {
7
+ const key = process.env.ENCRYPTION_KEY;
8
+ if (!key) {
9
+ throw new Error('ENCRYPTION_KEY is not set in environment variables');
10
+ }
11
+ return key;
12
+ }
13
+
14
+ export function encrypt(text: string): string {
15
+ const key = getEncryptionKey();
16
+ const iv = crypto.randomBytes(IV_LENGTH);
17
+ const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
18
+ let encrypted = cipher.update(text);
19
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
20
+ return iv.toString('hex') + ':' + encrypted.toString('hex');
21
+ }
22
+
23
+ export function decrypt(text: string): string {
24
+ const key = getEncryptionKey();
25
+ try {
26
+ const textParts = text.split(':');
27
+ const iv = Buffer.from(textParts.shift()!, 'hex');
28
+ const encryptedText = Buffer.from(textParts.join(':'), 'hex');
29
+ const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
30
+ let decrypted = decipher.update(encryptedText);
31
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
32
+ return decrypted.toString();
33
+ } catch (error) {
34
+ console.error('Decryption failed:', error);
35
+ // If decryption fails, return the original text as fallback (for migration)
36
+ // In a real app, you might want to handle this differently
37
+ return text;
38
+ }
39
+ }
package/src/lib/db.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { drizzle } from 'drizzle-orm/neon-serverless'
2
+ import { Pool } from '@neondatabase/serverless'
3
+ import * as schema from './schema'
4
+ import 'dotenv/config'
5
+
6
+ const connectionString = process.env.DATABASE_URL
7
+ if (!connectionString) {
8
+ throw new Error('DATABASE_URL is not set')
9
+ }
10
+
11
+ // Neon Serverless Pool supports transactions over WebSockets
12
+ const pool = new Pool({ connectionString })
13
+ export const db = drizzle(pool, { schema })
14
+
15
+ export type DB = typeof db
@@ -0,0 +1,19 @@
1
+ export async function resolveDynamicSecret(v: any) {
2
+ if (!v.isDynamic || !v.provider) return v.value;
3
+
4
+ try {
5
+ switch (v.provider) {
6
+ case 'aws':
7
+ // Mocking AWS SM call
8
+ return `dynamic-aws-resolved-${v.key}-value`;
9
+ case 'vault':
10
+ // Mocking Hashicorp Vault call
11
+ return `dynamic-vault-resolved-${v.key}-value`;
12
+ default:
13
+ return v.value;
14
+ }
15
+ } catch (error) {
16
+ console.error(`Failed to resolve dynamic secret ${v.id}:`, error);
17
+ return 'REF_ERROR: Failed to resolve dynamic secret';
18
+ }
19
+ }
@@ -0,0 +1,110 @@
1
+ import nodemailer from 'nodemailer';
2
+
3
+ // SMTP Configuration from environment variables
4
+ const SMTP_HOST = process.env.SMTP_HOST || 'smtp.mailtrap.io';
5
+ const SMTP_PORT = parseInt(process.env.SMTP_PORT || '2525');
6
+ const SMTP_USER = process.env.SMTP_USER || '';
7
+ const SMTP_PASS = process.env.SMTP_PASS || '';
8
+ const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@nobalmako.com';
9
+
10
+ const transporter = nodemailer.createTransport({
11
+ host: SMTP_HOST,
12
+ port: SMTP_PORT,
13
+ secure: SMTP_PORT === 465, // true for 465, false for other ports
14
+ auth: {
15
+ user: SMTP_USER,
16
+ pass: SMTP_PASS,
17
+ },
18
+ });
19
+
20
+ export async function sendEmail({ to, subject, html, text }: { to: string; subject: string; html: string; text?: string }) {
21
+ try {
22
+ const info = await transporter.sendMail({
23
+ from: `"Nobalmako" <${FROM_EMAIL}>`,
24
+ to,
25
+ subject,
26
+ text: text || subject, // Fallback if html not supported
27
+ html,
28
+ });
29
+ console.log('Email sent: %s', info.messageId);
30
+ return { success: true, messageId: info.messageId };
31
+ } catch (error) {
32
+ console.error('Error sending email:', error);
33
+ return { success: false, error };
34
+ }
35
+ }
36
+
37
+ // Pre-defined templates
38
+ export const emailTemplates = {
39
+ welcome: (name: string) => ({
40
+ subject: 'Welcome to Nobalmako! 🚀',
41
+ html: `
42
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
43
+ <h1 style="color: #2563eb;">Welcome, ${name}!</h1>
44
+ <p>We're thrilled to have you join Nobalmako. You're now ready to start securing and managing your environment variables with ease.</p>
45
+ <p>With Nobalmako, you can:</p>
46
+ <ul>
47
+ <li>Collaborate with your team on projects</li>
48
+ <li>Inherit variables between environments</li>
49
+ <li>Set up dynamic secrets with AWS & Vault</li>
50
+ <li>Lock down access with IP whitelisting and MFA</li>
51
+ </ul>
52
+ <a href="https://nobalmako.com/dashboard" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Go to Dashboard</a>
53
+ <p style="margin-top: 30px; font-size: 0.8em; color: #666;">If you have any questions, just reply to this email.</p>
54
+ </div>
55
+ `,
56
+ }),
57
+
58
+ verification: (name: string, token: string) => ({
59
+ subject: 'Verify your Nobalmako account',
60
+ html: `
61
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
62
+ <h1 style="color: #2563eb;">One last step, ${name}!</h1>
63
+ <p>Please click the button below to verify your email address and activate your account.</p>
64
+ <a href="https://nobalmako.com/api/auth/verify?token=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Verify Email</a>
65
+ <p style="margin-top: 20px;">Or copy and paste this link:</p>
66
+ <p style="font-size: 0.8em; color: #666; word-break: break-all;">https://nobalmako.com/api/auth/verify?token=${token}</p>
67
+ <p style="margin-top: 30px; font-size: 0.8em; color: #666;">This link will expire in 24 hours.</p>
68
+ </div>
69
+ `,
70
+ }),
71
+
72
+ mfaEnabled: (name: string) => ({
73
+ subject: 'MFA has been enabled on your account',
74
+ html: `
75
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
76
+ <h1 style="color: #10b981;">Security Update</h1>
77
+ <p>Hello ${name},</p>
78
+ <p>Multi-Factor Authentication (MFA) has been successfully enabled on your Nobalmako account. Your account is now even more secure.</p>
79
+ <p>If you did not perform this action, please contact our security team immediately.</p>
80
+ <a href="https://nobalmako.com/profile" style="display: inline-block; background-color: #10b981; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Manage Security Settings</a>
81
+ </div>
82
+ `,
83
+ }),
84
+
85
+ passwordReset: (name: string, token: string) => ({
86
+ subject: 'Reset your Nobalmako password',
87
+ html: `
88
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
89
+ <h1 style="color: #2563eb;">Password Reset Request</h1>
90
+ <p>Hello ${name},</p>
91
+ <p>We received a request to reset your password. Click the button below to choose a new one.</p>
92
+ <a href="https://nobalmako.com/auth/reset-password?token=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Reset Password</a>
93
+ <p style="margin-top: 20px; font-size: 0.8em; color: #666;">This link will expire in 1 hour. If you didn't request this, you can safely ignore this email.</p>
94
+ </div>
95
+ `,
96
+ }),
97
+
98
+ teamInvitation: (inviterName: string, teamName: string, teamId: string, token: string) => ({
99
+ subject: `Invitation to join ${teamName} on Nobalmako`,
100
+ html: `
101
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
102
+ <h1 style="color: #2563eb;">You've been invited!</h1>
103
+ <p>${inviterName} has invited you to join the team <strong>${teamName}</strong> on Nobalmako.</p>
104
+ <p>Nobalmako is a secure environment variable management platform that helps teams collaborate safely.</p>
105
+ <a href="https://nobalmako.com/auth/register?invite=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Accept Invitation</a>
106
+ <p style="margin-top: 20px; font-size: 0.8em; color: #666;">If you already have an account, make sure to log in first or use the same email address.</p>
107
+ </div>
108
+ `,
109
+ })
110
+ };
@@ -0,0 +1,51 @@
1
+ import nodemailer from 'nodemailer';
2
+
3
+ const transporter = nodemailer.createTransport({
4
+ host: process.env.SMTP_HOST,
5
+ port: parseInt(process.env.SMTP_PORT || '587'),
6
+ secure: process.env.SMTP_SECURE === 'true',
7
+ auth: {
8
+ user: process.env.SMTP_USER,
9
+ pass: process.env.SMTP_PASS,
10
+ },
11
+ });
12
+
13
+ export async function sendInviteEmail({
14
+ to,
15
+ teamName,
16
+ inviterName,
17
+ inviteLink
18
+ }: {
19
+ to: string,
20
+ teamName: string,
21
+ inviterName: string,
22
+ inviteLink: string
23
+ }) {
24
+ const mailOptions = {
25
+ from: `"Nobalmako" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
26
+ to,
27
+ subject: `You've been invited to join ${teamName} on Nobalmako`,
28
+ html: `
29
+ <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; rounded: 8px;">
30
+ <h2 style="color: #0f172a; margin-bottom: 24px;">Join your team on Nobalmako</h2>
31
+ <p style="color: #475569; font-size: 16px; line-height: 24px;">
32
+ <strong>${inviterName}</strong> has invited you to join the project <strong>${teamName}</strong> on Nobalmako.
33
+ </p>
34
+ <div style="margin: 32px 0;">
35
+ <a href="${inviteLink}" style="background-color: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">
36
+ Accept Invitation
37
+ </a>
38
+ </div>
39
+ <p style="color: #64748b; font-size: 14px; margin-top: 32px;">
40
+ If you don't have an account yet, you'll be asked to create one after clicking the button.
41
+ </p>
42
+ <hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 32px 0;" />
43
+ <p style="color: #94a3b8; font-size: 12px;">
44
+ Nobalmako - Secure Environment Variable Manager
45
+ </p>
46
+ </div>
47
+ `,
48
+ };
49
+
50
+ return transporter.sendMail(mailOptions);
51
+ }
@@ -0,0 +1,51 @@
1
+ import { db } from './db';
2
+ import { teamMembers, teams } from './schema';
3
+ import { and, eq } from 'drizzle-orm';
4
+
5
+ export type Role = 'owner' | 'admin' | 'developer' | 'viewer';
6
+
7
+ export const RoleHierarchy: Record<Role, number> = {
8
+ owner: 4,
9
+ admin: 3,
10
+ developer: 2,
11
+ viewer: 1,
12
+ };
13
+
14
+ export async function getUserRoleInTeam(userId: string, teamId: string): Promise<Role | null> {
15
+ // Check if user is the owner of the team
16
+ const team = await db
17
+ .select()
18
+ .from(teams)
19
+ .where(and(eq(teams.id, teamId), eq(teams.ownerId, userId)))
20
+ .limit(1);
21
+
22
+ if (team.length > 0) return 'owner';
23
+
24
+ // Check team_members table
25
+ const membership = await db
26
+ .select()
27
+ .from(teamMembers)
28
+ .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)))
29
+ .limit(1);
30
+
31
+ if (membership.length > 0) return membership[0].role as Role;
32
+
33
+ return null;
34
+ }
35
+
36
+ export async function hasPermission(
37
+ userId: string,
38
+ teamId: string,
39
+ requiredRole: Role
40
+ ): Promise<boolean> {
41
+ const userRole = await getUserRoleInTeam(userId, teamId);
42
+ if (!userRole) return false;
43
+
44
+ return RoleHierarchy[userRole] >= RoleHierarchy[requiredRole];
45
+ }
46
+
47
+ export const Permissions = {
48
+ MANAGE_TEAM: 'admin' as Role,
49
+ MANAGE_SECRETS: 'developer' as Role,
50
+ VIEW_SECRETS: 'viewer' as Role,
51
+ };
@@ -0,0 +1,240 @@
1
+ import { pgTable, text, timestamp, boolean, jsonb, index, uuid } from 'drizzle-orm/pg-core'
2
+ import { relations } from 'drizzle-orm'
3
+
4
+ // Users table
5
+ export const users = pgTable('users', {
6
+ id: uuid('id').primaryKey().defaultRandom(),
7
+ email: text('email').unique().notNull(),
8
+ name: text('full_name').notNull(),
9
+ password: text('password').notNull(),
10
+ avatar: text('avatar'),
11
+ role: text('role').default('user').notNull(), // 'user', 'admin'
12
+ emailVerified: timestamp('email_verified'),
13
+ verificationToken: text('verification_token'),
14
+ resetToken: text('reset_token'),
15
+ resetTokenExpiry: timestamp('reset_token_expiry'),
16
+ mfaEnabled: boolean('mfa_enabled').default(false).notNull(),
17
+ mfaSecret: text('mfa_secret'),
18
+ createdAt: timestamp('created_at').defaultNow().notNull(),
19
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
20
+ })
21
+
22
+ // Teams/Projects table
23
+ export const teams = pgTable('teams', {
24
+ id: uuid('id').primaryKey().defaultRandom(),
25
+ name: text('name').notNull(),
26
+ description: text('description'),
27
+ color: text('color').default('#3b82f6'),
28
+ ownerId: uuid('owner_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
29
+ createdAt: timestamp('created_at').defaultNow().notNull(),
30
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
31
+ }, (table) => ({
32
+ ownerIdx: index('teams_owner_idx').on(table.ownerId),
33
+ }))
34
+
35
+ // Team members
36
+ export const teamMembers = pgTable('team_members', {
37
+ id: uuid('id').primaryKey().defaultRandom(),
38
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
39
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
40
+ role: text('role').default('developer').notNull(), // 'owner', 'admin', 'developer', 'viewer'
41
+ joinedAt: timestamp('joined_at').defaultNow().notNull(),
42
+ }, (table) => ({
43
+ teamUserIdx: index('team_members_team_user_idx').on(table.teamId, table.userId),
44
+ }))
45
+
46
+ // Environments (dev, staging, prod, etc.)
47
+ export const environments = pgTable('environments', {
48
+ id: uuid('id').primaryKey().defaultRandom(),
49
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
50
+ parentId: uuid('parent_id').references((): any => environments.id), // For inheritance
51
+ name: text('name').notNull(),
52
+ description: text('description'),
53
+ color: text('color').default('#3b82f6'), // hex color
54
+ isDefault: boolean('is_default').default(false),
55
+ createdAt: timestamp('created_at').defaultNow().notNull(),
56
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
57
+ }, (table) => ({
58
+ teamIdx: index('environments_team_idx').on(table.teamId),
59
+ }))
60
+
61
+ // Environment Variables
62
+ export const environmentVariables = pgTable('environment_variables', {
63
+ id: uuid('id').primaryKey().defaultRandom(),
64
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
65
+ environmentId: uuid('environment_id').references(() => environments.id, { onDelete: 'cascade' }).notNull(),
66
+ key: text('key').notNull(),
67
+ value: text('value').notNull(),
68
+ description: text('description'),
69
+ isSecret: boolean('is_secret').default(false),
70
+ expiresAt: timestamp('expires_at'),
71
+ lastRotatedAt: timestamp('last_rotated_at'),
72
+ isDynamic: boolean('is_dynamic').default(false),
73
+ provider: text('provider'), // 'aws', 'vault', 'gcp'
74
+ providerConfig: jsonb('provider_config'),
75
+ tags: jsonb('tags').$type<string[]>(),
76
+ createdBy: uuid('created_by').references(() => users.id).notNull(),
77
+ updatedBy: uuid('updated_by').references(() => users.id),
78
+ createdAt: timestamp('created_at').defaultNow().notNull(),
79
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
80
+ }, (table) => ({
81
+ teamEnvIdx: index('env_vars_team_env_idx').on(table.teamId, table.environmentId),
82
+ keyIdx: index('env_vars_key_idx').on(table.key),
83
+ createdByIdx: index('env_vars_created_by_idx').on(table.createdBy),
84
+ }))
85
+
86
+ // API keys for external access
87
+ export const apiKeys = pgTable('api_keys', {
88
+ id: uuid('id').primaryKey().defaultRandom(),
89
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
90
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
91
+ name: text('name').notNull(),
92
+ keyHash: text('key_hash').notNull(), // Hashed API key
93
+ permissions: jsonb('permissions').$type<string[]>().default(['read']), // ['read', 'write', 'admin']
94
+ allowedIps: jsonb('allowed_ips').$type<string[]>().default([]), // CIDR or IPs
95
+ lastUsed: timestamp('last_used'),
96
+ expiresAt: timestamp('expires_at'),
97
+ createdAt: timestamp('created_at').defaultNow().notNull(),
98
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
99
+ }, (table) => ({
100
+ userIdx: index('api_keys_user_idx').on(table.userId),
101
+ teamIdx: index('api_keys_team_idx').on(table.teamId),
102
+ }))
103
+ export const variableHistory = pgTable('variable_history', {
104
+ id: uuid('id').primaryKey().defaultRandom(),
105
+ variableId: uuid('variable_id').notNull(),
106
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
107
+ environmentId: uuid('environment_id').references(() => environments.id, { onDelete: 'cascade' }).notNull(),
108
+ key: text('key').notNull(),
109
+ value: text('value').notNull(),
110
+ description: text('description'),
111
+ isSecret: boolean('is_secret').default(false),
112
+ changedBy: uuid('changed_by').references(() => users.id).notNull(),
113
+ changeType: text('change_type').notNull(), // 'create', 'update', 'delete'
114
+ createdAt: timestamp('created_at').defaultNow().notNull(),
115
+ }, (table) => ({
116
+ variableIdx: index('variable_history_variable_idx').on(table.variableId),
117
+ teamIdx: index('variable_history_team_idx').on(table.teamId),
118
+ }))
119
+ export const auditLogs = pgTable('audit_logs', {
120
+ id: uuid('id').primaryKey().defaultRandom(),
121
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
122
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
123
+ action: text('action').notNull(), // 'create', 'update', 'delete', 'view'
124
+ resourceType: text('resource_type').notNull(), // 'variable', 'environment', 'team'
125
+ resourceId: uuid('resource_id').notNull(),
126
+ oldValue: jsonb('old_value'),
127
+ newValue: jsonb('new_value'),
128
+ ipAddress: text('ip_address'),
129
+ userAgent: text('user_agent'),
130
+ createdAt: timestamp('created_at').defaultNow().notNull(),
131
+ }, (table) => ({
132
+ userIdx: index('audit_logs_user_idx').on(table.userId),
133
+ teamIdx: index('audit_logs_team_idx').on(table.teamId),
134
+ resourceIdx: index('audit_logs_resource_idx').on(table.resourceType, table.resourceId),
135
+ }))
136
+
137
+ // Invitations table
138
+ export const invitations = pgTable('invitations', {
139
+ id: uuid('id').primaryKey().defaultRandom(),
140
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
141
+ email: text('email').notNull(),
142
+ role: text('role').default('developer').notNull(), // 'admin', 'developer', 'viewer'
143
+ invitedBy: uuid('invited_by').references(() => users.id, { onDelete: 'cascade' }).notNull(),
144
+ token: text('token').unique().notNull(),
145
+ status: text('status').default('pending').notNull(), // 'pending', 'accepted', 'expired'
146
+ expiresAt: timestamp('expires_at').notNull(),
147
+ createdAt: timestamp('created_at').defaultNow().notNull(),
148
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
149
+ }, (table) => ({
150
+ teamIdx: index('invitations_team_idx').on(table.teamId),
151
+ emailIdx: index('invitations_email_idx').on(table.email),
152
+ tokenIdx: index('invitations_token_idx').on(table.token),
153
+ }))
154
+
155
+ // Webhooks for notifications
156
+ export const webhooks = pgTable('webhooks', {
157
+ id: uuid('id').primaryKey().defaultRandom(),
158
+ teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
159
+ url: text('url').notNull(),
160
+ name: text('name').notNull(),
161
+ events: jsonb('events').$type<string[]>().default(['variable.update', 'variable.delete']), // ['variable.create', 'variable.update', 'variable.delete']
162
+ secret: text('secret'),
163
+ isActive: boolean('is_active').default(true).notNull(),
164
+ createdAt: timestamp('created_at').defaultNow().notNull(),
165
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
166
+ }, (table) => ({
167
+ teamIdx: index('webhooks_team_idx').on(table.teamId),
168
+ }))
169
+
170
+ // Relations
171
+ export const usersRelations = relations(users, ({ many }) => ({
172
+ ownedTeams: many(teams, { relationName: 'teamOwner' }),
173
+ teamMemberships: many(teamMembers),
174
+ createdVariables: many(environmentVariables, { relationName: 'variableCreator' }),
175
+ updatedVariables: many(environmentVariables, { relationName: 'variableUpdater' }),
176
+ auditLogs: many(auditLogs),
177
+ sentInvitations: many(invitations),
178
+ }))
179
+
180
+ export const teamsRelations = relations(teams, ({ one, many }) => ({
181
+ owner: one(users, { fields: [teams.ownerId], references: [users.id], relationName: 'teamOwner' }),
182
+ members: many(teamMembers),
183
+ environments: many(environments),
184
+ variables: many(environmentVariables),
185
+ auditLogs: many(auditLogs),
186
+ invitations: many(invitations),
187
+ webhooks: many(webhooks),
188
+ }))
189
+
190
+ export const invitationsRelations = relations(invitations, ({ one }) => ({
191
+ team: one(teams, { fields: [invitations.teamId], references: [teams.id] }),
192
+ inviter: one(users, { fields: [invitations.invitedBy], references: [users.id] }),
193
+ }))
194
+
195
+ export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
196
+ team: one(teams, { fields: [teamMembers.teamId], references: [teams.id] }),
197
+ user: one(users, { fields: [teamMembers.userId], references: [users.id] }),
198
+ }))
199
+
200
+ export const environmentsRelations = relations(environments, ({ one, many }) => ({
201
+ team: one(teams, { fields: [environments.teamId], references: [teams.id] }),
202
+ variables: many(environmentVariables),
203
+ }))
204
+
205
+ export const environmentVariablesRelations = relations(environmentVariables, ({ one }) => ({
206
+ team: one(teams, { fields: [environmentVariables.teamId], references: [teams.id] }),
207
+ environment: one(environments, { fields: [environmentVariables.environmentId], references: [environments.id] }),
208
+ creator: one(users, { fields: [environmentVariables.createdBy], references: [users.id], relationName: 'variableCreator' }),
209
+ updater: one(users, { fields: [environmentVariables.updatedBy], references: [users.id], relationName: 'variableUpdater' }),
210
+ }))
211
+
212
+ export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
213
+ user: one(users, { fields: [apiKeys.userId], references: [users.id] }),
214
+ team: one(teams, { fields: [apiKeys.teamId], references: [teams.id] }),
215
+ }))
216
+
217
+ // Types
218
+ export type User = typeof users.$inferSelect
219
+ export type NewUser = typeof users.$inferInsert
220
+
221
+ export type Team = typeof teams.$inferSelect
222
+ export type NewTeam = typeof teams.$inferInsert
223
+
224
+ export type TeamMember = typeof teamMembers.$inferSelect
225
+ export type NewTeamMember = typeof teamMembers.$inferInsert
226
+
227
+ export type Environment = typeof environments.$inferSelect
228
+ export type NewEnvironment = typeof environments.$inferInsert
229
+
230
+ export type EnvironmentVariable = typeof environmentVariables.$inferSelect
231
+ export type NewEnvironmentVariable = typeof environmentVariables.$inferInsert
232
+
233
+ export type VariableHistory = typeof variableHistory.$inferSelect
234
+ export type NewVariableHistory = typeof variableHistory.$inferInsert
235
+
236
+ export type ApiKey = typeof apiKeys.$inferSelect
237
+ export type NewApiKey = typeof apiKeys.$inferInsert
238
+
239
+ export type AuditLog = typeof auditLogs.$inferSelect
240
+ export type NewAuditLog = typeof auditLogs.$inferInsert