humanenv 0.1.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 (36) hide show
  1. package/README.md +303 -0
  2. package/package.json +38 -0
  3. package/packages/cli/package.json +15 -0
  4. package/packages/cli/src/bin.js +228 -0
  5. package/packages/client/bin.js +174 -0
  6. package/packages/client/build.js +57 -0
  7. package/packages/client/dist/cli.js +1041 -0
  8. package/packages/client/dist/index.cjs +333 -0
  9. package/packages/client/dist/index.mjs +296 -0
  10. package/packages/client/package.json +24 -0
  11. package/packages/client/src/cli/bin.js +228 -0
  12. package/packages/client/src/cli/entry.js +465 -0
  13. package/packages/client/src/index.ts +31 -0
  14. package/packages/client/src/shared/buffer-shim.d.ts +4 -0
  15. package/packages/client/src/shared/crypto.ts +98 -0
  16. package/packages/client/src/shared/errors.ts +32 -0
  17. package/packages/client/src/shared/index.ts +3 -0
  18. package/packages/client/src/shared/types.ts +118 -0
  19. package/packages/client/src/ws-manager.ts +263 -0
  20. package/packages/server/package.json +21 -0
  21. package/packages/server/src/auth.ts +13 -0
  22. package/packages/server/src/db/index.ts +19 -0
  23. package/packages/server/src/db/interface.ts +33 -0
  24. package/packages/server/src/db/mongo.ts +166 -0
  25. package/packages/server/src/db/sqlite.ts +180 -0
  26. package/packages/server/src/index.ts +123 -0
  27. package/packages/server/src/pk-manager.ts +79 -0
  28. package/packages/server/src/routes/index.ts +110 -0
  29. package/packages/server/src/views/index.ejs +359 -0
  30. package/packages/server/src/ws/router.ts +263 -0
  31. package/packages/shared/package.json +13 -0
  32. package/packages/shared/src/buffer-shim.d.ts +4 -0
  33. package/packages/shared/src/crypto.ts +98 -0
  34. package/packages/shared/src/errors.ts +32 -0
  35. package/packages/shared/src/index.ts +3 -0
  36. package/packages/shared/src/types.ts +119 -0
@@ -0,0 +1,166 @@
1
+ import { MongoClient, Db } from 'mongodb'
2
+ import { IDatabaseProvider } from './interface'
3
+ import { HumanEnvError, ErrorCode } from 'humanenv-shared'
4
+ import crypto from 'node:crypto'
5
+
6
+ const COLLECTIONS = {
7
+ projects: 'projects',
8
+ envs: 'envs',
9
+ apiKeys: 'apiKeys',
10
+ whitelist: 'whitelist',
11
+ serverConfig: 'serverConfig',
12
+ } as const
13
+
14
+ export class MongoProvider implements IDatabaseProvider {
15
+ private client: MongoClient | null = null
16
+ private db: Db | null = null
17
+ private reconnectInterval: ReturnType<typeof setInterval> | null = null
18
+
19
+ constructor(private uri: string) {}
20
+
21
+ async connect(): Promise<void> {
22
+ this.client = new MongoClient(this.uri)
23
+ await this.client.connect()
24
+ this.db = this.client.db('humanenv')
25
+ this.initIndexes()
26
+ }
27
+
28
+ async disconnect(): Promise<void> {
29
+ if (this.reconnectInterval) clearInterval(this.reconnectInterval)
30
+ if (this.client) await this.client.close()
31
+ }
32
+
33
+ private initIndexes(): void {
34
+ const d = this.db!
35
+ d.collection(COLLECTIONS.projects).createIndex({ name: 1 }, { unique: true })
36
+ d.collection(COLLECTIONS.envs).createIndex({ projectId: 1, key: 1 }, { unique: true })
37
+ d.collection(COLLECTIONS.apiKeys).createIndex({ projectId: 1, lookupHash: 1 }, { unique: true })
38
+ d.collection(COLLECTIONS.whitelist).createIndex({ projectId: 1, fingerprint: 1 }, { unique: true })
39
+ d.collection(COLLECTIONS.serverConfig).createIndex({ key: 1 }, { unique: true })
40
+ }
41
+
42
+ async createProject(name: string): Promise<{ id: string }> {
43
+ const doc = { id: crypto.randomUUID(), name, createdAt: Date.now() }
44
+ await this.col('projects').insertOne(doc)
45
+ return { id: doc.id }
46
+ }
47
+
48
+ async getProject(name: string): Promise<{ id: string; name: string; createdAt: number } | null> {
49
+ const doc = await this.col('projects').findOne({ name }) as any
50
+ return doc ? { id: doc.id, name: doc.name, createdAt: doc.createdAt } : null
51
+ }
52
+
53
+ async listProjects(): Promise<Array<{ id: string; name: string; createdAt: number }>> {
54
+ return await this.col('projects').find({}).sort({ createdAt: -1 }).toArray() as any
55
+ }
56
+
57
+ async deleteProject(id: string): Promise<void> {
58
+ await this.col('projects').deleteOne({ id })
59
+ await this.col('envs').deleteMany({ projectId: id })
60
+ await this.col('apiKeys').deleteMany({ projectId: id })
61
+ await this.col('whitelist').deleteMany({ projectId: id })
62
+ }
63
+
64
+ async createEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<{ id: string }> {
65
+ const doc = { id: crypto.randomUUID(), projectId, key, encryptedValue, apiModeOnly, createdAt: Date.now() }
66
+ await this.col('envs').updateOne(
67
+ { projectId, key },
68
+ { $set: doc },
69
+ { upsert: true }
70
+ )
71
+ return { id: doc.id }
72
+ }
73
+
74
+ async getEnv(projectId: string, key: string): Promise<{ encryptedValue: string; apiModeOnly: boolean } | null> {
75
+ const doc = await this.col('envs').findOne({ projectId, key }) as any
76
+ return doc ? { encryptedValue: doc.encryptedValue, apiModeOnly: doc.apiModeOnly } : null
77
+ }
78
+
79
+ async listEnvs(projectId: string): Promise<Array<{ id: string; key: string; apiModeOnly: boolean; createdAt: number }>> {
80
+ return await this.col('envs').find({ projectId }).sort({ key: 1 }).toArray() as any
81
+ }
82
+
83
+ async updateEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<void> {
84
+ await this.col('envs').updateOne(
85
+ { projectId, key },
86
+ { $set: { encryptedValue, apiModeOnly } }
87
+ )
88
+ }
89
+
90
+ async deleteEnv(projectId: string, key: string): Promise<void> {
91
+ await this.col('envs').deleteOne({ projectId, key })
92
+ }
93
+
94
+ async createApiKey(projectId: string, encryptedValue: string, plainValue: string, ttl?: number): Promise<{ id: string }> {
95
+ const id = crypto.randomUUID()
96
+ const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined
97
+ const lookupHash = crypto.createHash('sha256').update(plainValue).digest('hex')
98
+ const doc = { id, projectId, encryptedValue, lookupHash, ttl: ttl ?? null, expiresAt: expiresAt ?? null, createdAt: Date.now() }
99
+ await this.col('apiKeys').insertOne(doc)
100
+ return { id }
101
+ }
102
+
103
+ async getApiKey(projectId: string, plainValue: string): Promise<{ id: string; expiresAt?: number } | null> {
104
+ const lookupHash = crypto.createHash('sha256').update(plainValue).digest('hex')
105
+ const doc = await this.col('apiKeys').findOne({ projectId, lookupHash }) as any
106
+ if (!doc) return null
107
+ if (doc.expiresAt && doc.expiresAt < Date.now()) return null
108
+ return { id: doc.id, expiresAt: doc.expiresAt }
109
+ }
110
+
111
+ async listApiKeys(projectId: string): Promise<Array<{ id: string; maskedPreview: string; ttl?: number; expiresAt?: number; createdAt: number }>> {
112
+ const docs = await this.col('apiKeys').find({ projectId }).sort({ createdAt: -1 }).toArray() as any[]
113
+ return docs.map(d => ({
114
+ id: d.id,
115
+ maskedPreview: d.lookupHash.slice(0, 8) + '...',
116
+ ttl: d.ttl,
117
+ expiresAt: d.expiresAt,
118
+ createdAt: d.createdAt,
119
+ }))
120
+ }
121
+
122
+ async revokeApiKey(projectId: string, id: string): Promise<void> {
123
+ await this.col('apiKeys').deleteOne({ id, projectId })
124
+ }
125
+
126
+ async createWhitelistEntry(projectId: string, fingerprint: string, status: 'pending' | 'approved' | 'rejected'): Promise<{ id: string }> {
127
+ const doc = { id: crypto.randomUUID(), projectId, fingerprint, status, createdAt: Date.now() }
128
+ await this.col('whitelist').updateOne(
129
+ { projectId, fingerprint },
130
+ { $set: doc },
131
+ { upsert: true }
132
+ )
133
+ return { id: doc.id }
134
+ }
135
+
136
+ async getWhitelistEntry(projectId: string, fingerprint: string): Promise<{ id: string; status: 'pending' | 'approved' | 'rejected' } | null> {
137
+ const doc = await this.col('whitelist').findOne({ projectId, fingerprint }) as any
138
+ return doc ? { id: doc.id, status: doc.status } : null
139
+ }
140
+
141
+ async listWhitelistEntries(projectId: string): Promise<Array<{ id: string; fingerprint: string; status: 'pending' | 'approved' | 'rejected'; createdAt: number }>> {
142
+ return await this.col('whitelist').find({ projectId }).sort({ createdAt: -1 }).toArray() as any
143
+ }
144
+
145
+ async updateWhitelistStatus(id: string, status: 'approved' | 'rejected'): Promise<void> {
146
+ await this.col('whitelist').updateOne({ id }, { $set: { status } })
147
+ }
148
+
149
+ async storePkHash(hash: string): Promise<void> {
150
+ await this.col('serverConfig').updateOne(
151
+ { key: 'pk_hash' },
152
+ { $set: { key: 'pk_hash', value: hash } },
153
+ { upsert: true }
154
+ )
155
+ }
156
+
157
+ async getPkHash(): Promise<string | null> {
158
+ const doc = await this.col('serverConfig').findOne({ key: 'pk_hash' }) as any
159
+ return doc?.value ?? null
160
+ }
161
+
162
+ private col(name: string) {
163
+ if (!this.db) throw new HumanEnvError(ErrorCode.DB_OPERATION_FAILED, 'MongoDB not connected')
164
+ return this.db.collection(name)
165
+ }
166
+ }
@@ -0,0 +1,180 @@
1
+ import BetterSqlite3 from 'better-sqlite3'
2
+ import { IDatabaseProvider } from './interface'
3
+ import crypto from 'node:crypto'
4
+
5
+ export class SqliteProvider implements IDatabaseProvider {
6
+ private db!: BetterSqlite3.Database
7
+
8
+ constructor(private dbPath: string) {}
9
+
10
+ async connect(): Promise<void> {
11
+ this.db = new BetterSqlite3(this.dbPath)
12
+ this.db.pragma('journal_mode = WAL')
13
+ this.initTables()
14
+ }
15
+
16
+ async disconnect(): Promise<void> {
17
+ this.db.close()
18
+ }
19
+
20
+ private initTables(): void {
21
+ this.db.exec(`
22
+ CREATE TABLE IF NOT EXISTS projects (
23
+ id TEXT PRIMARY KEY,
24
+ name TEXT UNIQUE NOT NULL,
25
+ created_at INTEGER NOT NULL
26
+ );
27
+ CREATE TABLE IF NOT EXISTS envs (
28
+ id TEXT PRIMARY KEY,
29
+ project_id TEXT NOT NULL,
30
+ key TEXT NOT NULL,
31
+ encrypted_value TEXT NOT NULL,
32
+ api_mode_only INTEGER NOT NULL DEFAULT 0,
33
+ created_at INTEGER NOT NULL,
34
+ UNIQUE(project_id, key),
35
+ FOREIGN KEY(project_id) REFERENCES projects(id)
36
+ );
37
+ CREATE TABLE IF NOT EXISTS api_keys (
38
+ id TEXT PRIMARY KEY,
39
+ project_id TEXT NOT NULL,
40
+ encrypted_value TEXT NOT NULL,
41
+ lookup_hash TEXT UNIQUE NOT NULL,
42
+ ttl INTEGER,
43
+ expires_at INTEGER,
44
+ created_at INTEGER NOT NULL,
45
+ FOREIGN KEY(project_id) REFERENCES projects(id)
46
+ );
47
+ CREATE TABLE IF NOT EXISTS whitelist (
48
+ id TEXT PRIMARY KEY,
49
+ project_id TEXT NOT NULL,
50
+ fingerprint TEXT NOT NULL,
51
+ status TEXT NOT NULL DEFAULT 'pending',
52
+ created_at INTEGER NOT NULL,
53
+ UNIQUE(project_id, fingerprint),
54
+ FOREIGN KEY(project_id) REFERENCES projects(id)
55
+ );
56
+ CREATE TABLE IF NOT EXISTS server_config (
57
+ key TEXT PRIMARY KEY,
58
+ value TEXT NOT NULL
59
+ );
60
+ `)
61
+ }
62
+
63
+ async createProject(name: string): Promise<{ id: string }> {
64
+ const id = crypto.randomUUID()
65
+ this.db.prepare('INSERT INTO projects (id, name, created_at) VALUES (?, ?, ?)').run(id, name, Date.now())
66
+ return { id }
67
+ }
68
+
69
+ async getProject(name: string): Promise<{ id: string; name: string; createdAt: number } | null> {
70
+ const row = this.db.prepare('SELECT id, name, created_at FROM projects WHERE name = ?').get(name) as any
71
+ return row ? { id: row.id, name: row.name, createdAt: row.created_at } : null
72
+ }
73
+
74
+ async listProjects(): Promise<Array<{ id: string; name: string; createdAt: number }>> {
75
+ const rows = this.db.prepare('SELECT id, name, created_at FROM projects ORDER BY created_at DESC').all() as any[]
76
+ return rows.map(r => ({ id: r.id, name: r.name, createdAt: r.created_at }))
77
+ }
78
+
79
+ async deleteProject(id: string): Promise<void> {
80
+ const tx = this.db.transaction(() => {
81
+ this.db.prepare('DELETE FROM envs WHERE project_id = ?').run(id)
82
+ this.db.prepare('DELETE FROM api_keys WHERE project_id = ?').run(id)
83
+ this.db.prepare('DELETE FROM whitelist WHERE project_id = ?').run(id)
84
+ this.db.prepare('DELETE FROM projects WHERE id = ?').run(id)
85
+ })
86
+ tx()
87
+ }
88
+
89
+ async createEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<{ id: string }> {
90
+ const id = crypto.randomUUID()
91
+ this.db.prepare('INSERT OR REPLACE INTO envs (id, project_id, key, encrypted_value, api_mode_only, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(
92
+ id, projectId, key, encryptedValue, apiModeOnly ? 1 : 0, Date.now()
93
+ )
94
+ return { id }
95
+ }
96
+
97
+ async getEnv(projectId: string, key: string): Promise<{ encryptedValue: string; apiModeOnly: boolean } | null> {
98
+ const row = this.db.prepare('SELECT encrypted_value, api_mode_only FROM envs WHERE project_id = ? AND key = ?').get(projectId, key) as any
99
+ return row ? { encryptedValue: row.encrypted_value, apiModeOnly: !!row.api_mode_only } : null
100
+ }
101
+
102
+ async listEnvs(projectId: string): Promise<Array<{ id: string; key: string; apiModeOnly: boolean; createdAt: number }>> {
103
+ const rows = this.db.prepare('SELECT id, key, api_mode_only, created_at FROM envs WHERE project_id = ? ORDER BY key').all(projectId) as any[]
104
+ return rows.map(r => ({ id: r.id, key: r.key, apiModeOnly: !!r.api_mode_only, createdAt: r.created_at }))
105
+ }
106
+
107
+ async updateEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<void> {
108
+ this.db.prepare('UPDATE envs SET encrypted_value = ?, api_mode_only = ? WHERE project_id = ? AND key = ?').run(
109
+ encryptedValue, apiModeOnly ? 1 : 0, projectId, key
110
+ )
111
+ }
112
+
113
+ async deleteEnv(projectId: string, key: string): Promise<void> {
114
+ this.db.prepare('DELETE FROM envs WHERE project_id = ? AND key = ?').run(projectId, key)
115
+ }
116
+
117
+ async createApiKey(projectId: string, encryptedValue: string, plainValue: string, ttl?: number): Promise<{ id: string }> {
118
+ const id = crypto.randomUUID()
119
+ const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined
120
+ const lookupHash = crypto.createHash('sha256').update(plainValue).digest('hex')
121
+ this.db.prepare('INSERT INTO api_keys (id, project_id, encrypted_value, lookup_hash, ttl, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
122
+ id, projectId, encryptedValue, lookupHash, ttl ?? null, expiresAt ?? null, Date.now()
123
+ )
124
+ return { id }
125
+ }
126
+
127
+ async getApiKey(projectId: string, plainValue: string): Promise<{ id: string; expiresAt?: number } | null> {
128
+ const lookupHash = crypto.createHash('sha256').update(plainValue).digest('hex')
129
+ const row = this.db.prepare('SELECT id, lookup_hash, expires_at FROM api_keys WHERE project_id = ? AND lookup_hash = ?').get(projectId, lookupHash) as any
130
+ if (!row) return null
131
+ if (row.expires_at && row.expires_at < Date.now()) return null
132
+ return { id: row.id, expiresAt: row.expires_at }
133
+ }
134
+
135
+ async listApiKeys(projectId: string): Promise<Array<{ id: string; maskedPreview: string; ttl?: number; expiresAt?: number; createdAt: number }>> {
136
+ const rows = this.db.prepare('SELECT id, lookup_hash, ttl, expires_at, created_at FROM api_keys WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as any[]
137
+ return rows.map(r => ({
138
+ id: r.id,
139
+ maskedPreview: r.lookup_hash.slice(0, 8) + '...',
140
+ ttl: r.ttl,
141
+ expiresAt: r.expires_at,
142
+ createdAt: r.created_at,
143
+ }))
144
+ }
145
+
146
+ async revokeApiKey(projectId: string, id: string): Promise<void> {
147
+ this.db.prepare('DELETE FROM api_keys WHERE id = ? AND project_id = ?').run(id, projectId)
148
+ }
149
+
150
+ async createWhitelistEntry(projectId: string, fingerprint: string, status: 'pending' | 'approved' | 'rejected'): Promise<{ id: string }> {
151
+ const id = crypto.randomUUID()
152
+ this.db.prepare('INSERT OR REPLACE INTO whitelist (id, project_id, fingerprint, status, created_at) VALUES (?, ?, ?, ?, ?)').run(
153
+ id, projectId, fingerprint, status, Date.now()
154
+ )
155
+ return { id }
156
+ }
157
+
158
+ async getWhitelistEntry(projectId: string, fingerprint: string): Promise<{ id: string; status: 'pending' | 'approved' | 'rejected' } | null> {
159
+ const row = this.db.prepare('SELECT id, status FROM whitelist WHERE project_id = ? AND fingerprint = ?').get(projectId, fingerprint) as any
160
+ return row ? { id: row.id, status: row.status } : null
161
+ }
162
+
163
+ async listWhitelistEntries(projectId: string): Promise<Array<{ id: string; fingerprint: string; status: 'pending' | 'approved' | 'rejected'; createdAt: number }>> {
164
+ const rows = this.db.prepare('SELECT id, fingerprint, status, created_at FROM whitelist WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as any[]
165
+ return rows.map(r => ({ id: r.id, fingerprint: r.fingerprint, status: r.status, createdAt: r.created_at }))
166
+ }
167
+
168
+ async updateWhitelistStatus(id: string, status: 'approved' | 'rejected'): Promise<void> {
169
+ this.db.prepare('UPDATE whitelist SET status = ? WHERE id = ?').run(status, id)
170
+ }
171
+
172
+ async storePkHash(hash: string): Promise<void> {
173
+ this.db.prepare('INSERT OR REPLACE INTO server_config (key, value) VALUES (?, ?)').run('pk_hash', hash)
174
+ }
175
+
176
+ async getPkHash(): Promise<string | null> {
177
+ const row = this.db.prepare('SELECT value FROM server_config WHERE key = ?').get('pk_hash') as { value: string } | undefined
178
+ return row?.value ?? null
179
+ }
180
+ }
@@ -0,0 +1,123 @@
1
+ import express from 'express'
2
+ import http from 'http'
3
+ import path from 'path'
4
+ import fs from 'fs'
5
+ import os from 'os'
6
+ import { PkManager } from './pk-manager'
7
+ import { createDatabase } from './db'
8
+ import { createBasicAuthMiddleware } from './auth'
9
+ import { createProjectsRouter, createEnvsRouter, createApiKeysRouter, createWhitelistRouter } from './routes'
10
+ import { WsRouter } from './ws/router'
11
+
12
+ // ============================================================
13
+ // Config resolution
14
+ // ============================================================
15
+
16
+ function parseIntOr(val: string | undefined, fallback: number): number {
17
+ if (!val) return fallback
18
+ const n = parseInt(val, 10)
19
+ return isNaN(n) ? fallback : n
20
+ }
21
+
22
+ const PORT = parseIntOr(
23
+ process.argv.find(a => a.startsWith('--port='))?.split('=')[1] ||
24
+ process.argv[process.argv.indexOf('--port') + 1] ||
25
+ process.env.PORT,
26
+ 3056
27
+ )
28
+ const BASIC_AUTH_ARG = process.argv.find(a => a.startsWith('--basicAuth'))
29
+ const dataDir = path.join(os.homedir(), '.humanenv')
30
+ const dbPath = path.join(dataDir, 'humanenv.db')
31
+
32
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
33
+
34
+ // ============================================================
35
+ // App bootstrap
36
+ // ============================================================
37
+
38
+ async function main() {
39
+ console.log('Starting HumanEnv server...')
40
+ console.log('Data directory:', dataDir)
41
+
42
+ // Database
43
+ const mongoUri = process.env.MONGODB_URI
44
+ const { provider: db, active: activeDb } = await createDatabase(dbPath, mongoUri)
45
+ console.log('Database:', activeDb)
46
+
47
+ // PK Manager
48
+ const pk = new PkManager()
49
+ const storedHash = await db.getPkHash()
50
+ const bootstrapResult = await pk.bootstrap(storedHash)
51
+
52
+ // App
53
+ const app = express()
54
+ const server = http.createServer(app)
55
+
56
+ app.use(express.json())
57
+
58
+ // Basic auth for admin UI
59
+ if (BASIC_AUTH_ARG) {
60
+ const username = process.env.BASIC_AUTH_USERNAME || 'admin'
61
+ const password = process.env.BASIC_AUTH_PASSWORD || 'admin'
62
+ app.use(createBasicAuthMiddleware(username, password))
63
+ }
64
+
65
+ // WS Router
66
+ const wsRouter = new WsRouter(server, db, pk)
67
+
68
+ // REST routes
69
+ app.use('/api/projects', createProjectsRouter(db, pk))
70
+ app.use('/api/envs', createEnvsRouter(db, pk))
71
+ app.use('/api/apikeys', createApiKeysRouter(db, pk))
72
+ app.use('/api/whitelist', createWhitelistRouter(db))
73
+
74
+ // PK setup endpoints
75
+ app.post('/api/pk/setup', async (req, res) => {
76
+ const { mnemonic } = req.body || {}
77
+ if (!mnemonic) return res.status(400).json({ error: 'mnemonic required' })
78
+ try {
79
+ const result = pk.submitMnemonic(mnemonic, storedHash)
80
+ await db.storePkHash(result.hash)
81
+ res.json({ ok: true, firstSetup: result.firstSetup })
82
+ } catch (e: any) {
83
+ res.status(400).json({ error: e.message })
84
+ }
85
+ })
86
+
87
+ app.get('/api/pk/generate', (_req, res) => {
88
+ const mnemonic = pk.getMnemonic()
89
+ res.json({ mnemonic })
90
+ })
91
+
92
+ app.get('/api/pk/status', (_req, res) => {
93
+ res.json({ ready: pk.isReady(), existing: bootstrapResult.existing })
94
+ })
95
+
96
+ // Serve admin UI
97
+ app.get('/', (_req, res) => {
98
+ const status = pk.isReady() ? 'ready' : bootstrapResult.status === 'needs_input' ? 'needs-pk' : 'ready'
99
+ const existing = bootstrapResult.existing || 'hash'
100
+ console.log('pk status for ejs:', status, existing)
101
+ res.render('index', {
102
+ pkStatus: status,
103
+ existing: existing,
104
+ activeDb: activeDb,
105
+ pkVerified: pk.isReady(),
106
+ })
107
+ })
108
+
109
+ app.set('view engine', 'ejs')
110
+ app.set('views', path.join(__dirname, 'views'))
111
+
112
+ // Start
113
+ server.listen(PORT, () => {
114
+ console.log('HumanEnv server listening on port', PORT)
115
+ console.log('Admin UI:', `http://localhost:${PORT}`)
116
+ if (!pk.isReady()) console.log('WARNING: PK not loaded. Admin must enter mnemonic to activate server.')
117
+ })
118
+ }
119
+
120
+ main().catch((e) => {
121
+ console.error('Fatal:', e)
122
+ process.exit(1)
123
+ })
@@ -0,0 +1,79 @@
1
+ import { derivePkFromMnemonic, hashPkForVerification, validateMnemonic, generateMnemonic, decryptWithPk, encryptWithPk, generateFingerprint } from 'humanenv-shared'
2
+ import { HumanEnvError, ErrorCode } from 'humanenv-shared'
3
+
4
+ export class PkManager {
5
+ private pk: Buffer | null = null
6
+ private mnemonic: string | null = null
7
+
8
+ async bootstrap(storedHash: string | null): Promise<{ status: 'ready' | 'needs_input'; existing?: 'hash' | 'first' }> {
9
+ const envMnemonic = process.env.HUMANENV_MNEMONIC
10
+ if (envMnemonic) {
11
+ const trimmed = envMnemonic.trim()
12
+ if (!validateMnemonic(trimmed)) {
13
+ throw new HumanEnvError(ErrorCode.SERVER_INTERNAL_ERROR, 'HUMANENV_MNEMONIC env contains invalid mnemonic')
14
+ }
15
+ this.mnemonic = trimmed
16
+ this.pk = derivePkFromMnemonic(trimmed)
17
+ const derivedHash = hashPkForVerification(this.pk)
18
+ if (storedHash && derivedHash !== storedHash) {
19
+ console.warn('WARN: Derived PK hash does not match stored hash. Data may be unrecoverable.')
20
+ }
21
+ console.log('PK restored from HUMANENV_MNEMONIC env var.')
22
+ return { status: 'ready', existing: storedHash ? 'hash' : 'first' }
23
+ }
24
+
25
+ if (!storedHash) {
26
+ return { status: 'needs_input', existing: 'first' }
27
+ }
28
+
29
+ return { status: 'needs_input', existing: 'hash' }
30
+ }
31
+
32
+ isReady(): boolean {
33
+ return this.pk !== null
34
+ }
35
+
36
+ getPk(): Uint8Array {
37
+ if (!this.pk) throw new HumanEnvError(ErrorCode.SERVER_PK_NOT_AVAILABLE)
38
+ return this.pk as unknown as Uint8Array
39
+ }
40
+
41
+ getMnemonic(): string {
42
+ if (!this.mnemonic) {
43
+ this.mnemonic = generateMnemonic()
44
+ }
45
+ return this.mnemonic
46
+ }
47
+
48
+ submitMnemonic(mnemonic: string, storedHash: string | null): { hash: string; verified: boolean; firstSetup: boolean } {
49
+ const trimmed = mnemonic.trim()
50
+ if (!validateMnemonic(trimmed)) {
51
+ throw new Error('Invalid mnemonic: must be a 12-word BIP39-compatible phrase')
52
+ }
53
+ const derived = derivePkFromMnemonic(trimmed)
54
+ const hash = hashPkForVerification(derived)
55
+
56
+ if (storedHash && hash !== storedHash) {
57
+ throw new Error('Mnemonic does not match the stored hash. Data was encrypted with a different key.')
58
+ }
59
+
60
+ this.pk = derived
61
+ this.mnemonic = trimmed
62
+ return { hash, verified: true, firstSetup: !storedHash }
63
+ }
64
+
65
+ encrypt(value: string, aad: string): string {
66
+ return encryptWithPk(value, this.getPk() as any, aad)
67
+ }
68
+
69
+ decrypt(encryptedValue: string, aad: string): string {
70
+ return decryptWithPk(encryptedValue, this.getPk() as any, aad)
71
+ }
72
+
73
+ clear(): void {
74
+ this.pk = null
75
+ this.mnemonic = null
76
+ }
77
+ }
78
+
79
+ export { generateFingerprint }
@@ -0,0 +1,110 @@
1
+ import { Router } from 'express'
2
+ import { IDatabaseProvider } from '../db/interface'
3
+ import { PkManager } from '../pk-manager'
4
+ import crypto from 'node:crypto'
5
+
6
+ export function createProjectsRouter(db: IDatabaseProvider, pk: PkManager): Router {
7
+ const router = Router()
8
+
9
+ router.get('/', async (_req, res) => {
10
+ const projects = await db.listProjects()
11
+ res.json(projects)
12
+ })
13
+
14
+ router.post('/', async (req, res) => {
15
+ const { name } = req.body || {}
16
+ if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' })
17
+ const existing = await db.getProject(name)
18
+ if (existing) return res.status(409).json({ error: 'Project already exists' })
19
+ const result = await db.createProject(name)
20
+ res.status(201).json({ id: result.id })
21
+ })
22
+
23
+ router.delete('/:id', async (req, res) => {
24
+ await db.deleteProject(req.params.id)
25
+ res.json({ ok: true })
26
+ })
27
+
28
+ return router
29
+ }
30
+
31
+ export function createEnvsRouter(db: IDatabaseProvider, pk: PkManager): Router {
32
+ const router = Router()
33
+
34
+ router.get('/project/:projectId', async (req, res) => {
35
+ const envs = await db.listEnvs(req.params.projectId)
36
+ res.json(envs)
37
+ })
38
+
39
+ router.post('/project/:projectId', async (req, res) => {
40
+ const { key, value, apiModeOnly } = req.body || {}
41
+ if (!key || value === undefined) return res.status(400).json({ error: 'key and value required' })
42
+ const encrypted = pk.encrypt(value, `${req.params.projectId}:${key}`)
43
+ const result = await db.createEnv(req.params.projectId, key, encrypted, !!apiModeOnly)
44
+ res.status(201).json({ id: result.id })
45
+ })
46
+
47
+ router.put('/project/:projectId', async (req, res) => {
48
+ const { key, value, apiModeOnly } = req.body || {}
49
+ if (!key || value === undefined) return res.status(400).json({ error: 'key and value required' })
50
+ const encrypted = pk.encrypt(value, `${req.params.projectId}:${key}`)
51
+ await db.updateEnv(req.params.projectId, key, encrypted, !!apiModeOnly)
52
+ res.json({ ok: true })
53
+ })
54
+
55
+ router.delete('/project/:projectId/:key', async (req, res) => {
56
+ await db.deleteEnv(req.params.projectId, decodeURIComponent(req.params.key))
57
+ res.json({ ok: true })
58
+ })
59
+
60
+ return router
61
+ }
62
+
63
+ export function createApiKeysRouter(db: IDatabaseProvider, pk: PkManager): Router {
64
+ const router = Router()
65
+
66
+ router.get('/project/:projectId', async (req, res) => {
67
+ const keys = await db.listApiKeys(req.params.projectId)
68
+ res.json(keys)
69
+ })
70
+
71
+ router.post('/project/:projectId', async (req, res) => {
72
+ const { plainKey, ttl } = req.body || {}
73
+ const keyToStore = plainKey || crypto.randomUUID()
74
+ const encrypted = pk.encrypt(keyToStore, `${req.params.projectId}:apikey:${keyToStore.slice(0, 8)}`)
75
+ const result = await db.createApiKey(req.params.projectId, encrypted, keyToStore, ttl)
76
+ res.status(201).json({ id: result.id, plainKey: keyToStore })
77
+ })
78
+
79
+ router.delete('/project/:projectId/:id', async (req, res) => {
80
+ await db.revokeApiKey(req.params.projectId, req.params.id)
81
+ res.json({ ok: true })
82
+ })
83
+
84
+ return router
85
+ }
86
+
87
+ export function createWhitelistRouter(db: IDatabaseProvider): Router {
88
+ const router = Router()
89
+
90
+ router.get('/project/:projectId', async (req, res) => {
91
+ const entries = await db.listWhitelistEntries(req.params.projectId)
92
+ res.json(entries)
93
+ })
94
+
95
+ router.post('/project/:projectId', async (req, res) => {
96
+ const { fingerprint, status } = req.body || {}
97
+ if (!fingerprint) return res.status(400).json({ error: 'fingerprint required' })
98
+ const result = await db.createWhitelistEntry(req.params.projectId, fingerprint, status || 'approved')
99
+ res.status(201).json({ id: result.id })
100
+ })
101
+
102
+ router.put('/project/:projectId/:id', async (req, res) => {
103
+ const { status } = req.body || {}
104
+ if (!status || !['approved', 'rejected'].includes(status)) return res.status(400).json({ error: 'status must be approved or rejected' })
105
+ await db.updateWhitelistStatus(req.params.id, status)
106
+ res.json({ ok: true })
107
+ })
108
+
109
+ return router
110
+ }