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,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,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,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
|
+
}
|