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,31 @@
1
+ import { HumanEnvClient, type ClientConfig } from './ws-manager.js';
2
+
3
+ export { HumanEnvClient };
4
+ export type { ClientConfig };
5
+
6
+ let singleton: HumanEnvClient | null = null
7
+ let configSet = false
8
+
9
+ async function ensure(): Promise<HumanEnvClient> {
10
+ if (!singleton) throw new Error('humanenv.config() must be called first')
11
+ return singleton
12
+ }
13
+
14
+ export default {
15
+ config(cfg: ClientConfig): void {
16
+ if (configSet) return
17
+ configSet = true
18
+ singleton = new HumanEnvClient(cfg)
19
+ },
20
+ async get(keyOrKeys: string | string[]): Promise<string | Record<string, string>> {
21
+ const client = await ensure()
22
+ return client.get(keyOrKeys as any)
23
+ },
24
+ async set(key: string, value: string): Promise<void> {
25
+ const client = await ensure()
26
+ return client.set(key, value)
27
+ },
28
+ disconnect(): void {
29
+ if (singleton) { singleton.disconnect(); singleton = null; configSet = false }
30
+ },
31
+ }
@@ -0,0 +1,4 @@
1
+ // Suppress @types/node Buffer/Uint8Array incompatibility with TS 5.9+
2
+ declare module 'node:buffer' {
3
+ interface Buffer extends Uint8Array {}
4
+ }
@@ -0,0 +1,98 @@
1
+ import * as crypto from 'node:crypto'
2
+
3
+ const PBKDF2_ITERATIONS = 100_000
4
+ const PK_KEY_LENGTH = 32
5
+
6
+ // ============================================================
7
+ // Mnemonic helpers (BIP39-compatible wordlist handling)
8
+ // ============================================================
9
+
10
+ const BIP39_WORDLIST = [
11
+ 'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract',
12
+ 'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid',
13
+ 'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual',
14
+ 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance',
15
+ 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
16
+ 'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album',
17
+ 'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone',
18
+ 'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among',
19
+ 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry',
20
+ 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna',
21
+ 'antique', 'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve',
22
+ 'april', 'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor',
23
+ 'army', 'around', 'arrest', 'arrive', 'arrow', 'art', 'artist', 'artwork',
24
+ 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', 'asthma', 'athlete',
25
+ 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august',
26
+ 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake',
27
+ 'aware', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon',
28
+ 'badge', 'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner',
29
+ 'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', 'basket', 'battle',
30
+ 'beach', 'bean', 'beauty', 'because', 'become', 'beef', 'before', 'begin',
31
+ 'behave', 'behind', 'believe', 'below', 'bench', 'benefit', 'best', 'betray',
32
+ 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology',
33
+ 'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast'
34
+ ]
35
+
36
+ export function generateMnemonic(): string {
37
+ const entropy = crypto.randomBytes(16)
38
+ const words: string[] = []
39
+ for (let i = 0; i < 32; i++) {
40
+ words.push(BIP39_WORDLIST[entropy[i]! % BIP39_WORDLIST.length])
41
+ }
42
+ return words.slice(0, 12).join(' ')
43
+ }
44
+
45
+ export function validateMnemonic(mnemonic: string): boolean {
46
+ const words = mnemonic.trim().toLowerCase().split(/\s+/)
47
+ if (words.length !== 12) return false
48
+ return words.every(w => BIP39_WORDLIST.includes(w))
49
+ }
50
+
51
+ export function derivePkFromMnemonic(mnemonic: string): Buffer {
52
+ return crypto.pbkdf2Sync(
53
+ mnemonic.toLowerCase().trim(),
54
+ 'humanenv-server-v1',
55
+ PBKDF2_ITERATIONS,
56
+ PK_KEY_LENGTH,
57
+ 'sha256'
58
+ ) as any
59
+ }
60
+
61
+ export function hashPkForVerification(pk: Buffer): string {
62
+ return crypto.createHash('sha256').update(pk as any).digest('hex')
63
+ }
64
+
65
+ export function encryptWithPk(value: string, pk: Buffer, aad: string): string {
66
+ const iv = crypto.randomBytes(12)
67
+ const cipher = crypto.createCipheriv('aes-256-gcm', pk as any, iv as any)
68
+ cipher.setAAD(crypto.createHash('sha256').update(aad).digest() as any)
69
+ const encrypted = Buffer.concat([cipher.update(value, 'utf8') as any, cipher.final() as any])
70
+ const tag = cipher.getAuthTag()
71
+ return Buffer.concat([iv as any, tag as any, encrypted as any]).toString('base64')
72
+ }
73
+
74
+ export function decryptWithPk(encryptedBase64: string, pk: Buffer, aad: string): string {
75
+ const buf = Buffer.from(encryptedBase64, 'base64')
76
+ const iv = buf.subarray(0, 12) as any
77
+ const tag = buf.subarray(12, 28) as any
78
+ const ciphertext = buf.subarray(28) as any
79
+ const decipher = crypto.createDecipheriv('aes-256-gcm', pk as any, iv as any)
80
+ decipher.setAAD(crypto.createHash('sha256').update(aad).digest() as any)
81
+ decipher.setAuthTag(tag)
82
+ const decrypted = Buffer.concat([decipher.update(ciphertext) as any, decipher.final() as any])
83
+ return decrypted.toString('utf8')
84
+ }
85
+
86
+ export function generateFingerprint(): string {
87
+ const components = [
88
+ process.env.HOSTNAME || 'unknown-host',
89
+ process.platform,
90
+ process.arch,
91
+ process.version,
92
+ ]
93
+ return crypto
94
+ .createHash('sha256')
95
+ .update(components.join('|'))
96
+ .digest('hex')
97
+ .slice(0, 16)
98
+ }
@@ -0,0 +1,32 @@
1
+ export enum ErrorCode {
2
+ SERVER_PK_NOT_AVAILABLE = 'SERVER_PK_NOT_AVAILABLE',
3
+ CLIENT_AUTH_INVALID_PROJECT_NAME = 'CLIENT_AUTH_INVALID_PROJECT_NAME',
4
+ CLIENT_AUTH_NOT_WHITELISTED = 'CLIENT_AUTH_NOT_WHITELISTED',
5
+ CLIENT_AUTH_INVALID_API_KEY = 'CLIENT_AUTH_INVALID_API_KEY',
6
+ CLIENT_CONN_MAX_RETRIES_EXCEEDED = 'CLIENT_CONN_MAX_RETRIES_EXCEEDED',
7
+ ENV_API_MODE_ONLY = 'ENV_API_MODE_ONLY',
8
+ SERVER_INTERNAL_ERROR = 'SERVER_INTERNAL_ERROR',
9
+ WS_CONNECTION_FAILED = 'WS_CONNECTION_FAILED',
10
+ DB_OPERATION_FAILED = 'DB_OPERATION_FAILED',
11
+ }
12
+
13
+ export const ErrorMessages: Record<ErrorCode, string> = {
14
+ SERVER_PK_NOT_AVAILABLE: 'Server private key is not available. Restart pending.',
15
+ CLIENT_AUTH_INVALID_PROJECT_NAME: 'Invalid or unknown project name.',
16
+ CLIENT_AUTH_NOT_WHITELISTED: 'Client fingerprint is not whitelisted for this project.',
17
+ CLIENT_AUTH_INVALID_API_KEY: 'Invalid or expired API key.',
18
+ CLIENT_CONN_MAX_RETRIES_EXCEEDED: 'Maximum WS connection retries exceeded.',
19
+ ENV_API_MODE_ONLY: 'This env is API-mode only and cannot be accessed via CLI.',
20
+ SERVER_INTERNAL_ERROR: 'An internal server error occurred.',
21
+ WS_CONNECTION_FAILED: 'Failed to establish WebSocket connection.',
22
+ DB_OPERATION_FAILED: 'Database operation failed.',
23
+ }
24
+
25
+ export class HumanEnvError extends Error {
26
+ public readonly code: ErrorCode
27
+ constructor(code: ErrorCode, message?: string) {
28
+ super(message ?? ErrorMessages[code])
29
+ this.name = 'HumanEnvError'
30
+ this.code = code
31
+ }
32
+ }
@@ -0,0 +1,3 @@
1
+ export * from './errors'
2
+ export * from './types'
3
+ export * from './crypto'
@@ -0,0 +1,118 @@
1
+ // ============================================================
2
+ // Domain models
3
+ // ============================================================
4
+
5
+ export interface Project {
6
+ id: string
7
+ name: string
8
+ createdAt: number
9
+ }
10
+
11
+ export interface Env {
12
+ id: string
13
+ projectId: string
14
+ key: string
15
+ encryptedValue: string
16
+ apiModeOnly: boolean
17
+ createdAt: number
18
+ }
19
+
20
+ export interface ApiKey {
21
+ id: string
22
+ projectId: string
23
+ encryptedValue: string
24
+ ttl?: number
25
+ expiresAt?: number
26
+ createdAt: number
27
+ }
28
+
29
+ export interface WhitelistEntry {
30
+ id: string
31
+ projectId: string
32
+ fingerprint: string
33
+ status: 'pending' | 'approved' | 'rejected'
34
+ createdAt: number
35
+ }
36
+
37
+ // ============================================================
38
+ // WebSocket message types
39
+ // ============================================================
40
+
41
+ export type WsMessage =
42
+ | { type: 'auth'; payload: AuthPayload }
43
+ | { type: 'auth_response'; payload: AuthResponse }
44
+ | { type: 'get'; payload: { key: string } }
45
+ | { type: 'get_response'; payload: { key: string; value: string } | { error: string; code: string } }
46
+ | { type: 'set'; payload: { key: string; value: string } }
47
+ | { type: 'set_response'; payload: { success: boolean } | { error: string; code: string } }
48
+ | { type: 'generate_api_key'; payload: { projectName: string } }
49
+ | { type: 'apikey_request'; payload: { clientFingerprint: string; projectName: string } }
50
+ | { type: 'apikey_response'; payload: { success: boolean; apiKey?: string } | { error: string; code: string } }
51
+ | { type: 'whitelist_request'; payload: { fingerprint: string; projectName: string } }
52
+ | { type: 'whitelist_response'; payload: { fingerprint: string; approved: boolean } }
53
+ | { type: 'disconnect'; payload?: never }
54
+ | { type: 'ping'; payload?: never }
55
+ | { type: 'pong'; payload?: never }
56
+
57
+ export interface AuthPayload {
58
+ projectName: string
59
+ apiKey: string
60
+ fingerprint: string
61
+ }
62
+
63
+ export interface AuthResponse {
64
+ success: boolean
65
+ whitelisted: boolean
66
+ error?: string
67
+ code?: string
68
+ }
69
+
70
+ // ============================================================
71
+ // Client library config
72
+ // ============================================================
73
+
74
+ export interface ClientConfig {
75
+ serverUrl: string
76
+ projectName: string
77
+ projectApiKey?: string
78
+ maxRetries?: number
79
+ }
80
+
81
+ export interface ServerConfig {
82
+ port: number
83
+ basicAuth?: { username: string; password: string }
84
+ dataDir: string
85
+ dbUri?: string
86
+ }
87
+
88
+ export type CredentialStore = {
89
+ projectName: string
90
+ serverUrl: string
91
+ apiKey?: string
92
+ }
93
+
94
+ export interface SkillMetadata {
95
+ name: string
96
+ description: string
97
+ category?: string
98
+ }
99
+
100
+ export const SKILL_CONTENT = `---
101
+ name: humanenv-usage
102
+ description: Use humanenv to retrieve sensitives/envs. Never log/read sensitives from humanenv. They are envs for humans only.
103
+ ---
104
+
105
+ ## How to retrieve sensitives
106
+
107
+ ### JavaScript API
108
+ Use \`humanenv.get('key')\` inside your application. The returned value must be used immediately and then set to \`null\` to prevent memory leaks.
109
+
110
+ ### CLI (non-TTY mode)
111
+ Only non-API-mode envs are accessible. Do not attempt to use this to log or dump all envs.
112
+
113
+ ## Rules
114
+ 1. NEVER log env values retrieved from humanenv
115
+ 2. NEVER dump or export multiple values at once
116
+ 3. ALWAYS null variables after use
117
+ 4. NEVER write sensitives to files
118
+ 5. Do not generate scripts that use humanenv in loops or to exfiltrate data`
@@ -0,0 +1,263 @@
1
+ import { generateFingerprint, ErrorCode, HumanEnvError } from './shared'
2
+ import WebSocket from 'ws'
3
+
4
+ export type ClientConfig = {
5
+ serverUrl: string
6
+ projectName: string
7
+ projectApiKey?: string
8
+ maxRetries?: number
9
+ }
10
+
11
+ type PendingOp = {
12
+ resolve: (v: any) => void
13
+ reject: (e: Error) => void
14
+ timeout: ReturnType<typeof setTimeout>
15
+ }
16
+
17
+ export class HumanEnvClient {
18
+ private ws: WebSocket | null = null
19
+ private connected = false
20
+ private authenticated = false
21
+ private _whitelistStatus: 'approved' | 'pending' | 'rejected' | null = null
22
+ private attempts = 0
23
+ private pending = new Map<string, PendingOp>()
24
+ private config: Required<ClientConfig>
25
+ private retryTimer: ReturnType<typeof setTimeout> | null = null
26
+ private pingTimer: ReturnType<typeof setInterval> | null = null
27
+ private reconnecting = false
28
+ private disconnecting = false
29
+ private _authResolve: (() => void) | null = null
30
+ private _authReject: ((e: Error) => void) | null = null
31
+
32
+ get whitelistStatus(): 'approved' | 'pending' | 'rejected' | null {
33
+ return this._whitelistStatus
34
+ }
35
+
36
+ constructor(config: ClientConfig) {
37
+ this.config = {
38
+ serverUrl: config.serverUrl,
39
+ projectName: config.projectName,
40
+ projectApiKey: config.projectApiKey || '',
41
+ maxRetries: config.maxRetries ?? 10,
42
+ }
43
+ }
44
+
45
+ private getFingerprint(): string {
46
+ return generateFingerprint()
47
+ }
48
+
49
+ async connect(): Promise<void> {
50
+ return new Promise((resolve, reject) => {
51
+ this.doConnect(resolve, reject)
52
+ })
53
+ }
54
+
55
+ private doConnect(resolve: () => void, reject: (e: Error) => void): void {
56
+ const proto = this.config.serverUrl.startsWith('https') ? 'wss' : 'ws'
57
+ const host = this.config.serverUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
58
+ const url = `${proto}://${host}/ws`
59
+
60
+ this.ws = new WebSocket(url)
61
+
62
+ this.ws.on('open', () => {
63
+ this.connected = true
64
+ this.attempts = 0
65
+ this.reconnecting = false
66
+ this.startPing()
67
+ this.sendAuth(resolve, reject)
68
+ })
69
+
70
+ this.ws.on('message', (raw: Buffer) => {
71
+ try {
72
+ const msg = JSON.parse(raw.toString())
73
+ this.handleMessage(msg)
74
+ } catch { /* ignore */ }
75
+ })
76
+
77
+ this.ws.on('close', () => {
78
+ this.connected = false
79
+ this.authenticated = false
80
+ this.stopPing()
81
+ if (!this.disconnecting && !this.reconnecting) this.scheduleReconnect(reject)
82
+ })
83
+
84
+ this.ws.on('error', () => { /* handled via close */ })
85
+ }
86
+
87
+ private sendAuth(resolve: () => void, reject: (e: Error) => void): void {
88
+ this._authResolve = resolve
89
+ this._authReject = reject
90
+ this.ws?.send(JSON.stringify({
91
+ type: 'auth',
92
+ payload: {
93
+ projectName: this.config.projectName,
94
+ apiKey: this.config.projectApiKey,
95
+ fingerprint: this.getFingerprint(),
96
+ }
97
+ }))
98
+ }
99
+
100
+ private handleMessage(msg: any): void {
101
+ if (msg.type === 'auth_response') {
102
+ if (msg.payload.success) {
103
+ this.authenticated = true
104
+ this._whitelistStatus = msg.payload.status || (msg.payload.whitelisted ? 'approved' : 'pending')
105
+ this._authResolve?.()
106
+ } else {
107
+ this._authReject?.(new HumanEnvError(msg.payload.code as ErrorCode, msg.payload.error))
108
+ }
109
+ this._authResolve = null
110
+ this._authReject = null
111
+ return
112
+ }
113
+
114
+ if (msg.type === 'get_response') {
115
+ this._resolvePending('get', msg.payload)
116
+ return
117
+ }
118
+
119
+ if (msg.type === 'set_response') {
120
+ this._resolvePending('set', msg.payload)
121
+ return
122
+ }
123
+
124
+ if (msg.type === 'pong') { /* keep-alive */ }
125
+ }
126
+
127
+ private _resolvePending(kind: 'get' | 'set', payload: any): void {
128
+ for (const [id, op] of this.pending) {
129
+ clearTimeout(op.timeout)
130
+ this.pending.delete(id)
131
+ if (payload.error) {
132
+ op.reject(new HumanEnvError(payload.code as ErrorCode, payload.error))
133
+ } else {
134
+ op.resolve(payload)
135
+ }
136
+ return
137
+ }
138
+ }
139
+
140
+ async get(key: string): Promise<string>
141
+ async get(keys: string[]): Promise<Record<string, string>>
142
+ async get(keyOrKeys: string | string[]): Promise<string | Record<string, string>> {
143
+ if (!this.connected || !this.authenticated) throw new HumanEnvError(ErrorCode.CLIENT_AUTH_INVALID_API_KEY)
144
+
145
+ if (Array.isArray(keyOrKeys)) {
146
+ const result: Record<string, string> = {}
147
+ await Promise.all(keyOrKeys.map(async (key) => {
148
+ result[key] = await this._getSingle(key)
149
+ }))
150
+ return result
151
+ }
152
+ return this._getSingle(keyOrKeys)
153
+ }
154
+
155
+ private _getSingle(key: string): Promise<string> {
156
+ return new Promise((resolve, reject) => {
157
+ const msgId = `${key}-${Date.now()}`
158
+ const timeout = setTimeout(() => {
159
+ this.pending.delete(msgId)
160
+ reject(new Error(`Timeout getting env: ${key}`))
161
+ }, 8000)
162
+ this.pending.set(msgId, { resolve: (v: any) => resolve(v.value), reject, timeout })
163
+ this.ws?.send(JSON.stringify({ type: 'get', payload: { key } }))
164
+ })
165
+ }
166
+
167
+ async set(key: string, value: string): Promise<void> {
168
+ if (!this.connected || !this.authenticated) throw new HumanEnvError(ErrorCode.CLIENT_AUTH_INVALID_API_KEY)
169
+ const msgId = `set-${Date.now()}`
170
+ return new Promise((resolve, reject) => {
171
+ const timeout = setTimeout(() => {
172
+ this.pending.delete(msgId)
173
+ reject(new Error(`Timeout setting env: ${key}`))
174
+ }, 8000)
175
+ this.pending.set(msgId, { resolve, reject, timeout })
176
+ this.ws?.send(JSON.stringify({ type: 'set', payload: { key, value } }))
177
+ })
178
+ }
179
+
180
+ private scheduleReconnect(reject: (e: Error) => void): void {
181
+ if (this.attempts >= this.config.maxRetries) {
182
+ reject(new HumanEnvError(ErrorCode.CLIENT_CONN_MAX_RETRIES_EXCEEDED))
183
+ return
184
+ }
185
+ this.reconnecting = true
186
+ this.attempts++
187
+ const delay = Math.min(1000 * Math.pow(2, this.attempts - 1), 30000)
188
+ if (process.stdout.isTTY) {
189
+ console.error(`[humanenv] Reconnecting in ${delay}ms (attempt ${this.attempts}/${this.config.maxRetries})...`)
190
+ }
191
+ this.retryTimer = setTimeout(() => {
192
+ this.doConnect(() => {}, reject)
193
+ }, delay)
194
+ }
195
+
196
+ private startPing(): void {
197
+ this.stopPing()
198
+ this.pingTimer = setInterval(() => {
199
+ if (this.ws?.readyState === WebSocket.OPEN) {
200
+ this.ws.send(JSON.stringify({ type: 'ping' }))
201
+ }
202
+ }, 30000)
203
+ }
204
+
205
+ private stopPing(): void {
206
+ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
207
+ }
208
+
209
+ /** Send a generate_api_key request to the server */
210
+ async generateApiKey(): Promise<string> {
211
+ if (!this.connected || !this.authenticated) throw new Error('Client not authenticated')
212
+ return new Promise((resolve, reject) => {
213
+ const msgId = `genkey-${Date.now()}`
214
+ const timeout = setTimeout(() => {
215
+ this.pending.delete(msgId)
216
+ reject(new Error('Timeout waiting for API key generation'))
217
+ }, 60000)
218
+ this.pending.set(msgId, { resolve: (v: any) => resolve(v.apiKey), reject, timeout })
219
+ this.ws?.send(JSON.stringify({ type: 'generate_api_key', payload: { projectName: this.config.projectName } }))
220
+ })
221
+ }
222
+
223
+ /** Connect (creates fresh WS) and waits for auth response up to `timeoutMs`. Resolves silently on timeout. */
224
+ async connectAndWaitForAuth(timeoutMs: number): Promise<void> {
225
+ return new Promise((resolve) => {
226
+ // If already connected and authenticated, resolve immediately
227
+ if (this.connected && this.authenticated) { resolve(); return }
228
+
229
+ const deadline = Date.now() + timeoutMs
230
+ const checkInterval = setInterval(() => {
231
+ if (this.connected && this.authenticated) {
232
+ clearInterval(checkInterval)
233
+ resolve()
234
+ return
235
+ }
236
+ if (Date.now() >= deadline) {
237
+ clearInterval(checkInterval)
238
+ resolve()
239
+ return
240
+ }
241
+ }, 200)
242
+
243
+ // If not connected, establish connection
244
+ if (!this.connected) {
245
+ this.attempts = 0
246
+ this.doConnect(() => {
247
+ // connected, now waiting for auth
248
+ }, () => {
249
+ clearInterval(checkInterval)
250
+ resolve()
251
+ })
252
+ }
253
+ })
254
+ }
255
+
256
+ disconnect(): void {
257
+ this.stopPing()
258
+ if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null }
259
+ this.disconnecting = true
260
+ this.reconnecting = false
261
+ this.ws?.close()
262
+ }
263
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "humanenv-server",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "./src/index.ts",
6
+ "scripts": {
7
+ "dev": "tsx src/index.ts",
8
+ "build": "tsc --noEmit",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "humanenv-shared": "file:../shared",
13
+ "express": "^4.21.0",
14
+ "ws": "^8.18.0",
15
+ "better-sqlite3": "^11.6.0",
16
+ "mongodb": "^6.11.0",
17
+ "ejs": "^3.1.10",
18
+ "basic-auth": "^2.0.1",
19
+ "tsx": "^4.19.0"
20
+ }
21
+ }
@@ -0,0 +1,13 @@
1
+ import express from 'express'
2
+ import basicAuth from 'basic-auth'
3
+
4
+ export function createBasicAuthMiddleware(username: string, password: string): express.RequestHandler {
5
+ return (req, res, next) => {
6
+ const credentials = basicAuth(req)
7
+ if (!credentials || credentials.name !== username || credentials.pass !== password) {
8
+ res.set('WWW-Authenticate', 'Basic realm="HumanEnv Admin"')
9
+ return res.status(401).send('Authentication required')
10
+ }
11
+ next()
12
+ }
13
+ }
@@ -0,0 +1,19 @@
1
+ import { IDatabaseProvider } from './interface'
2
+ import { SqliteProvider } from './sqlite'
3
+ import { MongoProvider } from './mongo'
4
+
5
+ export async function createDatabase(dataDir: string, mongoUri?: string): Promise<{ provider: IDatabaseProvider; active: 'sqlite' | 'mongodb' }> {
6
+ if (mongoUri) {
7
+ try {
8
+ const mongo = new MongoProvider(mongoUri)
9
+ await mongo.connect()
10
+ return { provider: mongo, active: 'mongodb' }
11
+ } catch (e) {
12
+ console.warn('WARN:', (e as Error).message + '\nFalling back to SQLite.')
13
+ }
14
+ }
15
+
16
+ const sqlite = new SqliteProvider(dataDir)
17
+ await sqlite.connect()
18
+ return { provider: sqlite, active: 'sqlite' }
19
+ }
@@ -0,0 +1,33 @@
1
+ export interface IDatabaseProvider {
2
+ connect(): Promise<void>
3
+ disconnect(): Promise<void>
4
+
5
+ // Project CRUD
6
+ createProject(name: string): Promise<{ id: string }>
7
+ getProject(name: string): Promise<{ id: string; name: string; createdAt: number } | null>
8
+ listProjects(): Promise<Array<{ id: string; name: string; createdAt: number }>>
9
+ deleteProject(id: string): Promise<void>
10
+
11
+ // Env CRUD
12
+ createEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<{ id: string }>
13
+ getEnv(projectId: string, key: string): Promise<{ encryptedValue: string; apiModeOnly: boolean } | null>
14
+ listEnvs(projectId: string): Promise<Array<{ id: string; key: string; apiModeOnly: boolean; createdAt: number }>>
15
+ updateEnv(projectId: string, key: string, encryptedValue: string, apiModeOnly: boolean): Promise<void>
16
+ deleteEnv(projectId: string, key: string): Promise<void>
17
+
18
+ // API Key CRUD
19
+ createApiKey(projectId: string, encryptedValue: string, plainValue: string, ttl?: number): Promise<{ id: string }>
20
+ getApiKey(projectId: string, plainValue: string): Promise<{ id: string; expiresAt?: number } | null>
21
+ listApiKeys(projectId: string): Promise<Array<{ id: string; maskedPreview: string; ttl?: number; expiresAt?: number; createdAt: number }>>
22
+ revokeApiKey(projectId: string, id: string): Promise<void>
23
+
24
+ // Whitelist CRUD
25
+ createWhitelistEntry(projectId: string, fingerprint: string, status: 'pending' | 'approved' | 'rejected'): Promise<{ id: string }>
26
+ getWhitelistEntry(projectId: string, fingerprint: string): Promise<{ id: string; status: 'pending' | 'approved' | 'rejected' } | null>
27
+ listWhitelistEntries(projectId: string): Promise<Array<{ id: string; fingerprint: string; status: 'pending' | 'approved' | 'rejected'; createdAt: number }>>
28
+ updateWhitelistStatus(id: string, status: 'approved' | 'rejected'): Promise<void>
29
+
30
+ // PK verification (stores hash only)
31
+ storePkHash(hash: string): Promise<void>
32
+ getPkHash(): Promise<string | null>
33
+ }