@buildappolis/env-manager
Version:
Enterprise-grade environment variable management system with encryption, snapshots, and validation
554 lines (472 loc) • 16.1 kB
text/typescript
import fs from 'fs/promises'
import fsSync from 'fs'
import path from 'path'
import crypto from 'crypto'
import os from 'os'
import type { EnvVariable, DatabaseVariable, HistoryEntry, Snapshot } from '../types.js'
export default class EnvDatabase {
private dbPath: string
private data: {
variables: DatabaseVariable[]
history: HistoryEntry[]
snapshots: Snapshot[]
auth: {
passwordHash?: string
salt?: string
isAuthenticated: boolean
lastAuth?: number
}
hotReload?: {
enabled: boolean
autoReload: boolean
reloadDelay: number
wsPort: number
notifyOnly: boolean
}
metadata?: Record<string, string>
}
private encryptionKey: string | null = null
constructor(dbPath?: string) {
const projectRoot = process.env.PROJECT_ROOT || process.cwd()
this.dbPath = dbPath || path.join(projectRoot, 'env-data.json')
this.data = {
variables: [],
history: [],
snapshots: [],
auth: {
isAuthenticated: false
},
metadata: {}
}
this.loadData()
this.loadMasterPasswordIfNeeded()
}
private async loadData(): Promise<void> {
try {
const fileContent = await fs.readFile(this.dbPath, 'utf-8')
this.data = JSON.parse(fileContent)
} catch (error) {
// File doesn't exist or is invalid, start with empty data
await this.saveData()
}
}
private async saveData(): Promise<void> {
try {
await fs.writeFile(this.dbPath, JSON.stringify(this.data, null, 2))
} catch (error) {
console.error('Failed to save database:', error)
throw error
}
}
private generateSalt(): string {
return crypto.randomBytes(16).toString('hex')
}
private hashPassword(password: string, salt: string): string {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex')
}
private deriveEncryptionKey(password: string, salt: string): string {
return crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512').toString('hex')
}
private encrypt(text: string): string {
if (!this.encryptionKey) {
// If no encryption key (no password set), return the value as-is
// This allows sensitive variables to be stored without encryption when no password is set
return text
}
// Ensure encryption key is a Buffer of the right length
const keyBuffer = typeof this.encryptionKey === 'string'
? Buffer.from(this.encryptionKey, 'hex').slice(0, 32)
: this.encryptionKey
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return iv.toString('hex') + ':' + encrypted
}
private decrypt(encryptedText: string): string {
if (!this.encryptionKey) {
// If no encryption key, assume the value is not encrypted
return encryptedText
}
// Check if the text looks encrypted (has the IV prefix)
if (!encryptedText.includes(':')) {
// Not encrypted, return as-is
return encryptedText
}
// Ensure encryption key is a Buffer of the right length
const keyBuffer = typeof this.encryptionKey === 'string'
? Buffer.from(this.encryptionKey, 'hex').slice(0, 32)
: this.encryptionKey
const [ivHex, encrypted] = encryptedText.split(':')
const decipher = crypto.createDecipheriv('aes-256-cbc', keyBuffer, Buffer.from(ivHex, 'hex'))
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
private loadSecureCredentials(): { passwordHash: string; salt: string; encryptionKey: string } | null {
try {
const credPath = path.join(os.homedir(), '.env-manager', 'credentials.json')
if (fsSync.existsSync(credPath)) {
const credentials = JSON.parse(fsSync.readFileSync(credPath, 'utf-8'))
return credentials
}
} catch (error) {
console.error('Failed to load secure credentials:', error)
}
return null
}
private loadMasterPasswordIfNeeded(): void {
// If no project password is set but we need encryption for sensitive vars,
// try to load the master password
if (!this.data.auth.passwordHash && !this.data.auth.salt) {
const masterCreds = this.loadSecureCredentials()
if (masterCreds) {
// Use master password's encryption key for this project
this.encryptionKey = masterCreds.encryptionKey
// Mark as authenticated since we have master password
this.data.auth.isAuthenticated = true
}
}
}
authenticate(password: string): boolean {
try {
// First try to load credentials from secure storage
const secureCredentials = this.loadSecureCredentials()
if (secureCredentials) {
// Use secure credentials from home directory
const hash = this.hashPassword(password, secureCredentials.salt)
if (hash === secureCredentials.passwordHash) {
this.data.auth.isAuthenticated = true
this.data.auth.lastAuth = Date.now()
// Use the stored encryption key instead of deriving from password
this.encryptionKey = secureCredentials.encryptionKey
this.saveData()
return true
}
return false
}
// Fallback to old method for backward compatibility
// First time setup
if (!this.data.auth.passwordHash || !this.data.auth.salt) {
const salt = this.generateSalt()
const hash = this.hashPassword(password, salt)
this.data.auth.salt = salt
this.data.auth.passwordHash = hash
this.data.auth.isAuthenticated = true
this.data.auth.lastAuth = Date.now()
this.encryptionKey = this.deriveEncryptionKey(password, salt)
this.saveData()
return true
}
// Verify existing password (old method)
const hash = this.hashPassword(password, this.data.auth.salt!)
if (hash === this.data.auth.passwordHash) {
this.data.auth.isAuthenticated = true
this.data.auth.lastAuth = Date.now()
this.encryptionKey = this.deriveEncryptionKey(password, this.data.auth.salt!)
this.saveData()
return true
}
return false
} catch (error) {
console.error('Authentication error:', error)
return false
}
}
isAuthenticated(): boolean {
// If no password is set, consider it always authenticated
if (!this.data.auth.passwordHash && !this.data.auth.salt) {
return true
}
// Otherwise check both auth status and encryption key
return this.data.auth.isAuthenticated && !!this.encryptionKey
}
logout(): void {
this.data.auth.isAuthenticated = false
this.encryptionKey = null
this.saveData()
}
setVariable(name: string, value: string, metadata: Partial<DatabaseVariable> = {}): DatabaseVariable {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const now = new Date().toISOString()
// Find existing variable considering branch
const branch = metadata.branch || 'main'
const existingIndex = this.data.variables.findIndex(v =>
v.name === name && (v.branch || 'main') === branch
)
const variable: DatabaseVariable = {
name,
value: metadata.sensitive ? this.encrypt(value) : value,
category: metadata.category || 'other',
description: metadata.description || '',
sensitive: metadata.sensitive || false,
encrypted: (metadata.sensitive || false) && !!this.encryptionKey,
branch: metadata.branch,
environment: metadata.environment,
createdAt: existingIndex >= 0 ? this.data.variables[existingIndex].createdAt : now,
updatedAt: now
}
// Add to history
this.data.history.push({
id: crypto.randomUUID(),
action: existingIndex >= 0 ? 'update' : 'create',
variableName: name,
oldValue: existingIndex >= 0 ? this.data.variables[existingIndex].value : undefined,
newValue: variable.value,
timestamp: now
})
if (existingIndex >= 0) {
this.data.variables[existingIndex] = variable
} else {
this.data.variables.push(variable)
}
this.saveData()
return variable
}
updateVariable(name: string, value: string, metadata: Partial<EnvVariable> = {}): DatabaseVariable {
return this.setVariable(name, value, metadata)
}
getVariable(name: string): DatabaseVariable | null {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const variable = this.data.variables.find(v => v.name === name)
if (!variable) return null
// Decrypt if necessary
if (variable.encrypted && variable.sensitive) {
return {
...variable,
value: this.decrypt(variable.value)
}
}
return variable
}
getAllVariables(branch?: string): DatabaseVariable[] {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const targetBranch = branch || 'main'
// Filter variables by branch, with fallback to main branch
const branchVariables = this.data.variables.filter(v => {
const varBranch = v.branch || 'main'
return varBranch === targetBranch || (targetBranch !== 'main' && varBranch === 'main')
})
// Deduplicate - branch-specific overrides main
const variableMap = new Map<string, DatabaseVariable>()
// First add main branch variables
branchVariables
.filter(v => (v.branch || 'main') === 'main')
.forEach(v => variableMap.set(v.name, v))
// Then override with branch-specific
if (targetBranch !== 'main') {
branchVariables
.filter(v => v.branch === targetBranch)
.forEach(v => variableMap.set(v.name, v))
}
return Array.from(variableMap.values()).map(variable => {
if (variable.encrypted && variable.sensitive) {
try {
return {
...variable,
value: this.decrypt(variable.value)
}
} catch (error) {
console.error(`Failed to decrypt variable ${variable.name}:`, error)
return {
...variable,
value: '[DECRYPTION_FAILED]'
}
}
}
return variable
})
}
getVariablesByBranch(branch: string): DatabaseVariable[] {
return this.getAllVariables(branch)
}
getBranches(): string[] {
const branches = new Set<string>()
this.data.variables.forEach(v => {
branches.add(v.branch || 'main')
})
return Array.from(branches)
}
deleteVariable(name: string): boolean {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const index = this.data.variables.findIndex(v => v.name === name)
if (index === -1) return false
const variable = this.data.variables[index]
// Add to history
this.data.history.push({
id: crypto.randomUUID(),
action: 'delete',
variableName: name,
oldValue: variable.value,
timestamp: new Date().toISOString()
})
this.data.variables.splice(index, 1)
this.saveData()
return true
}
getHistory(): HistoryEntry[] {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
return this.data.history.slice().reverse() // Most recent first
}
createSnapshot(name: string, description?: string): Snapshot {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const snapshot: Snapshot = {
id: crypto.randomUUID(),
name,
description: description || '',
variables: JSON.parse(JSON.stringify(this.data.variables)),
createdAt: new Date().toISOString()
}
this.data.snapshots.push(snapshot)
this.saveData()
return snapshot
}
getSnapshots(): Snapshot[] {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
return this.data.snapshots.slice().reverse() // Most recent first
}
restoreSnapshot(snapshotId: string): boolean {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const snapshot = this.data.snapshots.find(s => s.id === snapshotId)
if (!snapshot) return false
// Create a backup snapshot first
this.createSnapshot(`Backup before restore ${snapshot.name}`, 'Auto-created before snapshot restore')
// Restore variables
this.data.variables = JSON.parse(JSON.stringify(snapshot.variables))
// Add to history
this.data.history.push({
id: crypto.randomUUID(),
action: 'restore',
variableName: `Snapshot: ${snapshot.name}`,
timestamp: new Date().toISOString()
})
this.saveData()
return true
}
deleteSnapshot(snapshotId: string): boolean {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const index = this.data.snapshots.findIndex(s => s.id === snapshotId)
if (index === -1) return false
this.data.snapshots.splice(index, 1)
this.saveData()
return true
}
generateEnvFile(): string {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const variables = this.getAllVariables()
const lines: string[] = []
// Group by category
const categories = new Map<string, DatabaseVariable[]>()
variables.forEach(variable => {
const category = variable.category || 'other'
if (!categories.has(category)) {
categories.set(category, [])
}
categories.get(category)!.push(variable)
})
// Generate file content
categories.forEach((vars, category) => {
lines.push(`# ${category.toUpperCase()}`)
vars.forEach(variable => {
if (variable.description) {
lines.push(`# ${variable.description}`)
}
lines.push(`${variable.name}=${variable.value}`)
})
lines.push('')
})
return lines.join('\n')
}
generateEnvExample(): string {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated')
}
const variables = this.getAllVariables()
const lines: string[] = []
// Group by category
const categories = new Map<string, DatabaseVariable[]>()
variables.forEach(variable => {
const category = variable.category || 'other'
if (!categories.has(category)) {
categories.set(category, [])
}
categories.get(category)!.push(variable)
})
// Generate example file content
categories.forEach((vars, category) => {
lines.push(`# ${category.toUpperCase()}`)
vars.forEach(variable => {
if (variable.description) {
lines.push(`# ${variable.description}`)
}
const placeholder = variable.sensitive ?
'your_secret_here' :
variable.value || 'your_value_here'
lines.push(`${variable.name}=${placeholder}`)
})
lines.push('')
})
return lines.join('\n')
}
// Hot-reload settings management
getHotReloadSettings(): any {
if (!this.data.hotReload) {
this.data.hotReload = {
enabled: true,
autoReload: true,
reloadDelay: 1000,
wsPort: 3002,
notifyOnly: false
}
this.saveData()
}
return this.data.hotReload
}
setHotReloadSettings(settings: any): void {
this.data.hotReload = {
...this.getHotReloadSettings(),
...settings
}
this.saveData()
}
// Metadata methods for draft/publish system
getMetadata(key: string): string | undefined {
return this.data.metadata?.[key]
}
setMetadata(key: string, value: string): void {
if (!this.data.metadata) {
this.data.metadata = {}
}
this.data.metadata[key] = value
this.saveData()
}
deleteMetadata(key: string): void {
if (this.data.metadata) {
delete this.data.metadata[key]
this.saveData()
}
}
getAllMetadata(): Record<string, string> {
return { ...(this.data.metadata || {}) }
}
}