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.
- package/README.md +303 -0
- package/package.json +38 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/bin.js +228 -0
- package/packages/client/bin.js +174 -0
- package/packages/client/build.js +57 -0
- package/packages/client/dist/cli.js +1041 -0
- package/packages/client/dist/index.cjs +333 -0
- package/packages/client/dist/index.mjs +296 -0
- package/packages/client/package.json +24 -0
- package/packages/client/src/cli/bin.js +228 -0
- package/packages/client/src/cli/entry.js +465 -0
- package/packages/client/src/index.ts +31 -0
- package/packages/client/src/shared/buffer-shim.d.ts +4 -0
- package/packages/client/src/shared/crypto.ts +98 -0
- package/packages/client/src/shared/errors.ts +32 -0
- package/packages/client/src/shared/index.ts +3 -0
- package/packages/client/src/shared/types.ts +118 -0
- package/packages/client/src/ws-manager.ts +263 -0
- package/packages/server/package.json +21 -0
- package/packages/server/src/auth.ts +13 -0
- package/packages/server/src/db/index.ts +19 -0
- package/packages/server/src/db/interface.ts +33 -0
- package/packages/server/src/db/mongo.ts +166 -0
- package/packages/server/src/db/sqlite.ts +180 -0
- package/packages/server/src/index.ts +123 -0
- package/packages/server/src/pk-manager.ts +79 -0
- package/packages/server/src/routes/index.ts +110 -0
- package/packages/server/src/views/index.ejs +359 -0
- package/packages/server/src/ws/router.ts +263 -0
- package/packages/shared/package.json +13 -0
- package/packages/shared/src/buffer-shim.d.ts +4 -0
- package/packages/shared/src/crypto.ts +98 -0
- package/packages/shared/src/errors.ts +32 -0
- package/packages/shared/src/index.ts +3 -0
- 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
|
+
}
|