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,359 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HumanEnv Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css" rel="stylesheet">
9
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
10
+ <style>
11
+ [v-cloak] { display: none }
12
+ .mono { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; }
13
+ </style>
14
+ </head>
15
+ <body class="min-h-screen bg-base-200">
16
+ <div id="app" v-cloak class="container mx-auto p-4">
17
+
18
+ <!-- PK Setup -->
19
+ <template v-if="pkStatus === 'needs-pk'">
20
+ <div class="card bg-base-100 shadow-xl max-w-lg mx-auto mt-20">
21
+ <div class="card-body">
22
+ <h2 class="card-title text-2xl">Setup Private Key</h2>
23
+ <p class="text-base-content/70">This server needs a private key to encrypt/decrypt sensitive values. If lost, all data is unrecoverable.</p>
24
+
25
+ <div v-if="existing === 'first'" class="mt-4">
26
+ <p class="font-semibold mb-2">Generated 12-word recovery phrase:</p>
27
+ <textarea readonly :value="generatedMnemonic" class="textarea textarea-bordered w-full mono text-lg bg-warning/10 h-24"></textarea>
28
+ <div class="alert alert-warning mt-2">
29
+ <span>Save this phrase securely. It will not be stored. If lost, data cannot be recovered.</span>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="divider"></div>
34
+
35
+ <div class="form-control">
36
+ <label class="label"><span class="label-text">Enter or confirm 12-word phrase</span></label>
37
+ <textarea v-model="submittedMnemonic" class="textarea textarea-bordered w-full mono" placeholder="word1 word2 word3..." rows="3"></textarea>
38
+ </div>
39
+
40
+ <p v-if="pkError" class="text-error mt-2 font-bold">{{ pkError }}</p>
41
+
42
+ <div class="card-actions justify-end mt-4">
43
+ <button @click="submitPk" class="btn btn-primary" :disabled="pkSubmitting">{{ pkSubmitting ? 'Verifying...' : 'Submit' }}</button>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </template>
48
+
49
+ <!-- Main Dashboard -->
50
+ <template v-else>
51
+ <div class="navbar bg-base-100 shadow-lg mb-4 rounded-box">
52
+ <a class="btn btn-ghost text-xl">HumanEnv Admin</a>
53
+ <div class="flex-1"></div>
54
+ <div class="badge badge-outline">{{ activeDb }} connected</div>
55
+ </div>
56
+
57
+ <!-- Projects -->
58
+ <div class="card bg-base-100 shadow-lg mb-4">
59
+ <div class="card-body">
60
+ <h2 class="card-title">Projects</h2>
61
+ <div class="flex gap-2 mt-2">
62
+ <input v-model="newProjectName" placeholder="Project name" class="input input-bordered input-sm flex-1" @keyup.enter="createProject">
63
+ <button @click="createProject" class="btn btn-primary btn-sm">Create</button>
64
+ </div>
65
+ <ul class="mt-2">
66
+ <li v-for="p in projects" :key="p.id" class="flex justify-between items-center py-2 border-b last:border-b-0">
67
+ <div>
68
+ <span class="font-bold">{{ p.name }}</span>
69
+ <span class="text-xs text-base-content/50 ml-2">{{ new Date(p.createdAt).toLocaleString() }}</span>
70
+ </div>
71
+ <div class="flex gap-2">
72
+ <button @click="selectProject(p)" class="btn btn-ghost btn-xs">Manage</button>
73
+ <button @click="deleteProject(p)" class="btn btn-error btn-xs">Delete</button>
74
+ </div>
75
+ </li>
76
+ </ul>
77
+ </div>
78
+ </div>
79
+
80
+ <!-- Selected Project Detail -->
81
+ <template v-if="selectedProject">
82
+ <div class="card bg-base-100 shadow-lg">
83
+ <div class="card-body">
84
+ <h2 class="card-title flex justify-between items-center">
85
+ <span>{{ selectedProject.name }}</span>
86
+ <button @click="selectedProject = null" class="btn btn-ghost btn-sm">Close</button>
87
+ </h2>
88
+
89
+ <div role="tablist" class="tabs tabs-lifted tabs-primary mt-4">
90
+ <button @click="activeTab = 'envs'" role="tab" class="tab" :class="{ 'tab-active': activeTab === 'envs' }">Envs</button>
91
+ <button @click="activeTab = 'keys'" role="tab" class="tab" :class="{ 'tab-active': activeTab === 'keys' }">API Keys</button>
92
+ <button @click="activeTab = 'whitelist'" role="tab" class="tab" :class="{ 'tab-active': activeTab === 'whitelist' }">Whitelist</button>
93
+ <button @click="activeTab = 'settings'" role="tab" class="tab" :class="{ 'tab-active': activeTab === 'settings' }">Settings</button>
94
+ </div>
95
+
96
+ <!-- Envs Tab -->
97
+ <template v-if="activeTab === 'envs'">
98
+ <div class="mt-4">
99
+ <div class="flex gap-2 mb-2">
100
+ <input v-model="envForm.key" placeholder="KEY_NAME" class="input input-bordered input-sm mono flex-1">
101
+ <input v-model="envForm.value" placeholder="value" class="input input-bordered input-sm flex-1">
102
+ <label class="label cursor-pointer gap-2"><span class="label-text text-xs">api-mode-only</span><input type="checkbox" v-model="envForm.apiModeOnly" class="toggle toggle-sm toggle-primary"></label>
103
+ <button @click="saveEnv" class="btn btn-primary btn-sm">{{ envExists ? 'Update' : 'Save' }}</button>
104
+ </div>
105
+ <table class="table table-xs">
106
+ <thead><tr><th>Key</th><th>api-mode-only</th><th>Created</th><th></th></tr></thead>
107
+ <tbody>
108
+ <tr v-for="env in envs" :key="env.id" @click="envForm.key = env.key; envForm.apiModeOnly = env.apiModeOnly;" class="cursor-pointer hover:bg-base-200">
109
+ <td class="mono font-bold">{{ env.key }}</td>
110
+ <td><span v-if="env.apiModeOnly" class="badge badge-warning badge-sm">api-only</span><span v-else class="badge badge-ghost badge-sm">cli-ok</span></td>
111
+ <td>{{ new Date(env.createdAt).toLocaleString() }}</td>
112
+ <td><button @click.stop="deleteEnv(env.key)" class="btn btn-error btn-xs">Delete</button></td>
113
+ </tr>
114
+ </tbody>
115
+ </table>
116
+ </div>
117
+ </template>
118
+
119
+ <!-- API Keys Tab -->
120
+ <template v-if="activeTab === 'keys'">
121
+ <div class="mt-4">
122
+ <div class="flex gap-2 mb-2 items-center">
123
+ <input v-model="newKeyTtl" placeholder="TTL in seconds (optional)" type="number" class="input input-bordered input-sm w-48">
124
+ <button @click="createApiKey" class="btn btn-primary btn-sm">Generate Key</button>
125
+ <label class="label cursor-pointer gap-2 ml-4"><span class="label-text text-xs">Auto-accept client generation requests</span><input type="checkbox" v-model="autoAcceptApiKey" class="toggle toggle-sm toggle-primary"></label>
126
+ </div>
127
+ <p v-if="lastCreatedKey" class="text-success font-bold mono mt-2">Created: {{ lastCreatedKey }} <span class="text-xs text-base-content/50">(copy now, it will not be shown again)</span></p>
128
+ <table class="table table-xs mt-2">
129
+ <thead><tr><th>Preview</th><th>TTL</th><th>Expires</th><th>Created</th><th></th></tr></thead>
130
+ <tbody>
131
+ <tr v-for="k in apiKeys" :key="k.id">
132
+ <td class="mono">{{ k.maskedPreview }}</td>
133
+ <td>{{ k.ttl ? k.ttl + 's' : 'never' }}</td>
134
+ <td>{{ k.expiresAt ? new Date(k.expiresAt).toLocaleString() : 'never' }}</td>
135
+ <td>{{ new Date(k.createdAt).toLocaleString() }}</td>
136
+ <td><button @click="revokeKey(k.id)" class="btn btn-error btn-xs">Revoke</button></td>
137
+ </tr>
138
+ </tbody>
139
+ </table>
140
+ </div>
141
+ </template>
142
+
143
+ <!-- Whitelist Tab -->
144
+ <template v-if="activeTab === 'whitelist'">
145
+ <div class="mt-4">
146
+ <div class="flex gap-2 mb-2">
147
+ <input v-model="wlForm.fingerprint" placeholder="Client fingerprint (SHA-256 prefix)" class="input input-bordered input-sm mono flex-1">
148
+ <button @click="addWhitelist" class="btn btn-primary btn-sm">Add (approved)</button>
149
+ </div>
150
+ <table class="table table-xs mt-2">
151
+ <thead><tr><th>Fingerprint</th><th>Status</th><th>Created</th><th></th></tr></thead>
152
+ <tbody>
153
+ <tr v-for="wl in whitelist" :key="wl.id" :class="{ 'bg-warning/20': wl.status === 'pending' }">
154
+ <td class="mono">{{ wl.fingerprint }}</td>
155
+ <td>
156
+ <span v-if="wl.status === 'approved'" class="badge badge-success badge-sm">approved</span>
157
+ <span v-else-if="wl.status === 'rejected'" class="badge badge-error badge-sm">rejected</span>
158
+ <span v-else class="badge badge-warning badge-sm">pending</span>
159
+ </td>
160
+ <td>{{ new Date(wl.createdAt).toLocaleString() }}</td>
161
+ <td>
162
+ <template v-if="wl.status === 'pending'">
163
+ <button @click="approveWhitelist(wl)" class="btn btn-success btn-xs mr-1">Accept</button>
164
+ <button @click="rejectWhitelist(wl)" class="btn btn-error btn-xs">Reject</button>
165
+ </template>
166
+ <template v-else>
167
+ <button @click="deleteWhitelist(wl)" class="btn btn-ghost btn-xs">Remove</button>
168
+ </template>
169
+ </td>
170
+ </tr>
171
+ </tbody>
172
+ </table>
173
+ </div>
174
+ </template>
175
+
176
+ <!-- Settings Tab -->
177
+ <template v-if="activeTab === 'settings'">
178
+ <div class="mt-4 space-y-4">
179
+ <p class="text-sm text-base-content/70">Database type: <strong class="mono">{{ activeDb }}</strong></p>
180
+ <p class="text-sm text-base-content/70">Server PK: <strong v-if="pkVerified" class="text-success">Loaded</strong><span v-else class="text-warning">Needs input</span></p>
181
+ </div>
182
+ </template>
183
+
184
+ </div>
185
+ </div>
186
+ </template>
187
+
188
+ <!-- Real-time notification -->
189
+ <div v-if="pendingNotifications.length" class="toast toast-end">
190
+ <div v-for="n in pendingNotifications" :key="n.fingerprint" class="alert alert-warning shadow-lg">
191
+ <div>
192
+ <h3 class="font-bold">New whitelist request</h3>
193
+ <p class="text-xs mono">{{ n.projectName }} - fingerprint: {{ n.fingerprint }}</p>
194
+ <div class="flex gap-2 mt-2">
195
+ <button @click="approveNotification(n)" class="btn btn-success btn-xs">Approve</button>
196
+ <button @click="rejectNotification(n)" class="btn btn-error btn-xs">Reject</button>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </template>
202
+ </div>
203
+
204
+ <script>
205
+ const { createApp, ref, computed, watch, onMounted } = Vue
206
+
207
+ createApp({
208
+ setup() {
209
+ const pkStatus = ref('<%- pkStatus %>')
210
+ const existing = ref('<%- existing %>')
211
+ const generatedMnemonic = ref('')
212
+ const submittedMnemonic = ref('')
213
+ const pkError = ref('')
214
+ const pkSubmitting = ref(false)
215
+ const activeDb = ref('<%- activeDb %>')
216
+ const pkVerified = ref(false)
217
+
218
+ const projects = ref([])
219
+ const newProjectName = ref('')
220
+ const selectedProject = ref(null)
221
+ const activeTab = ref('envs')
222
+
223
+ const envs = ref([])
224
+ const envForm = ref({ key: '', value: '', apiModeOnly: false })
225
+
226
+ const apiKeys = ref([])
227
+ const newKeyTtl = ref(undefined)
228
+ const lastCreatedKey = ref('')
229
+ const autoAcceptApiKey = ref(false)
230
+
231
+ const whitelist = ref([])
232
+ const wlForm = ref({ fingerprint: '' })
233
+
234
+ const pendingNotifications = ref([])
235
+ const ws = ref(null)
236
+
237
+ const envExists = computed(() => envs.value.some(e => e.key === envForm.value.key))
238
+
239
+ function api(path, opts = {}) {
240
+ return fetch('/api' + path, { headers: { 'Content-Type': 'application/json' }, ...opts }).then(r => r.json())
241
+ }
242
+
243
+ function connectAdminWs() {
244
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws'
245
+ const url = proto + '://' + location.host + '/ws/admin'
246
+ ws.value = new WebSocket(url)
247
+ ws.value.onmessage = (e) => {
248
+ const data = JSON.parse(e.data)
249
+ if (data.event === 'whitelist_pending') {
250
+ pendingNotifications.value.push(data.payload)
251
+ }
252
+ if (data.event === 'apikey_gen_request') {
253
+ pendingNotifications.value.push({ ...data.payload, type: 'apikey' })
254
+ }
255
+ }
256
+ ws.value.onclose = () => setTimeout(connectAdminWs, 3000)
257
+ }
258
+
259
+ async function submitPk() {
260
+ if (!submittedMnemonic.value.trim()) { pkError.value = 'Enter mnemonic'; return }
261
+ pkSubmitting.value = true
262
+ pkError.value = ''
263
+ try {
264
+ const res = await fetch('/api/pk/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mnemonic: submittedMnemonic.value }) })
265
+ const data = await res.json()
266
+ if (!res.ok) { pkError.value = data.error; pkSubmitting.value = false; return }
267
+ pkStatus.value = 'ready'
268
+ pkVerified.value = true
269
+ await loadProjects()
270
+ connectAdminWs()
271
+ } catch (e) { pkError.value = e.message; pkSubmitting.value = false }
272
+ }
273
+
274
+ async function loadProjects() { projects.value = await api('/projects') }
275
+ async function createProject() {
276
+ if (!newProjectName.value) return
277
+ await api('/projects', { method: 'POST', body: JSON.stringify({ name: newProjectName.value }) })
278
+ newProjectName.value = ''
279
+ await loadProjects()
280
+ }
281
+ async function deleteProject(p) { if (confirm('Delete project "' + p.name + '" and all its data?')) { await api('/projects/' + p.id, { method: 'DELETE' }); selectedProject.value = null; await loadProjects() } }
282
+ async function selectProject(p) { selectedProject.value = p; await loadProjectData() }
283
+
284
+ async function loadProjectData() {
285
+ if (!selectedProject.value) return
286
+ envs.value = await api('/envs/project/' + selectedProject.value.id)
287
+ apiKeys.value = await api('/apikeys/project/' + selectedProject.value.id)
288
+ whitelist.value = await api('/whitelist/project/' + selectedProject.value.id)
289
+ autoAcceptApiKey.value = false
290
+ }
291
+
292
+ async function saveEnv() {
293
+ const project = selectedProject.value
294
+ if (!project || !envForm.value.key) return
295
+ if (envExists.value) {
296
+ await api('/envs/project/' + project.id, { method: 'PUT', body: JSON.stringify(envForm.value) })
297
+ } else {
298
+ await api('/envs/project/' + project.id, { method: 'POST', body: JSON.stringify(envForm.value) })
299
+ }
300
+ envForm.value.value = ''
301
+ await loadProjectData()
302
+ }
303
+ async function deleteEnv(key) { await api('/envs/project/' + selectedProject.value.id + '/' + encodeURIComponent(key), { method: 'DELETE' }); await loadProjectData() }
304
+
305
+ async function createApiKey() {
306
+ const res = await api('/apikeys/project/' + selectedProject.value.id, { method: 'POST', body: JSON.stringify({ ttl: newKeyTtl.value || undefined }) })
307
+ lastCreatedKey.value = res.plainKey
308
+ await loadProjectData()
309
+ }
310
+ async function revokeKey(id) { await api('/apikeys/project/' + selectedProject.value.id + '/' + id, { method: 'DELETE' }); await loadProjectData() }
311
+
312
+ async function addWhitelist() {
313
+ if (!wlForm.value.fingerprint) return
314
+ await api('/whitelist/project/' + selectedProject.value.id, { method: 'POST', body: JSON.stringify({ fingerprint: wlForm.value.fingerprint, status: 'approved' }) })
315
+ wlForm.value.fingerprint = ''
316
+ await loadProjectData()
317
+ }
318
+ async function approveWhitelist(wl) { await api('/whitelist/project/' + selectedProject.value.id + '/' + wl.id, { method: 'PUT', body: JSON.stringify({ status: 'approved' }) }); await loadProjectData() }
319
+ async function rejectWhitelist(wl) { await api('/whitelist/project/' + selectedProject.value.id + '/' + wl.id, { method: 'PUT', body: JSON.stringify({ status: 'rejected' }) }); await loadProjectData() }
320
+ async function deleteWhitelist(wl) { /* simple delete not needed, just set rejected and ignore */ wl.status = 'rejected'; }
321
+
322
+ async function approveNotification(n) {
323
+ await api('/whitelist/project/' + n.projectId, { method: 'POST', body: JSON.stringify({ fingerprint: n.fingerprint, status: 'approved' }) })
324
+ pendingNotifications.value = pendingNotifications.value.filter(x => x.fingerprint !== n.fingerprint)
325
+ }
326
+ async function rejectNotification(n) {
327
+ await api('/whitelist/project/' + n.projectId, { method: 'POST', body: JSON.stringify({ fingerprint: n.fingerprint, status: 'rejected' }) })
328
+ pendingNotifications.value = pendingNotifications.value.filter(x => x.fingerprint !== n.fingerprint)
329
+ }
330
+
331
+ onMounted(async () => {
332
+ if (existing.value === 'first') {
333
+ const res = await fetch('/api/pk/generate')
334
+ const data = await res.json()
335
+ generatedMnemonic.value = data.mnemonic
336
+ submittedMnemonic.value = data.mnemonic
337
+ }
338
+ if (pkStatus.value === 'ready') {
339
+ pkVerified.value = true
340
+ await loadProjects()
341
+ connectAdminWs()
342
+ }
343
+ })
344
+
345
+ return {
346
+ pkStatus, existing, generatedMnemonic, submittedMnemonic, pkError, pkSubmitting, pkVerified,
347
+ activeDb, projects, newProjectName, selectedProject, activeTab,
348
+ envs, envForm, envExists, apiKeys, newKeyTtl, lastCreatedKey, autoAcceptApiKey,
349
+ whitelist, wlForm, pendingNotifications,
350
+ loadProjects, createProject, deleteProject, selectProject,
351
+ submitPk, saveEnv, deleteEnv, createApiKey, revokeKey,
352
+ addWhitelist, approveWhitelist, rejectWhitelist, deleteWhitelist,
353
+ approveNotification, rejectNotification
354
+ }
355
+ }
356
+ }).mount('#app')
357
+ </script>
358
+ </body>
359
+ </html>
@@ -0,0 +1,263 @@
1
+ import { WebSocket, WebSocketServer } from 'ws'
2
+ import { IncomingMessage } from 'http'
3
+ import { IDatabaseProvider } from '../db/interface'
4
+ import { PkManager } from '../pk-manager'
5
+ import { ErrorCode, HumanEnvError, ErrorMessages, WsMessage, AuthResponse } from 'humanenv-shared'
6
+
7
+ type PendingRequestResolver = { resolve: (msg: any) => void; reject: (e: any) => void; timeout: ReturnType<typeof setTimeout> }
8
+
9
+ export class WsRouter {
10
+ private wss: WebSocketServer
11
+ private pendingRequests = new Map<string, PendingRequestResolver>()
12
+ private adminClients = new Set<WebSocket>()
13
+ private clientSessions = new Map<WebSocket, { projectName: string; fingerprint: string; authenticated: boolean }>()
14
+ private autoAcceptApiKey = false
15
+
16
+ constructor(
17
+ private server: any,
18
+ private db: IDatabaseProvider,
19
+ private pk: PkManager
20
+ ) {
21
+ this.wss = new WebSocketServer({ server, path: '/ws' })
22
+ this.wss.on('connection', this.onConnection.bind(this))
23
+ }
24
+
25
+ /** Register admin UI WS clients */
26
+ registerAdminClient(ws: WebSocket): void {
27
+ this.adminClients.add(ws)
28
+ ws.on('close', () => this.adminClients.delete(ws))
29
+ ws.on('message', (raw) => {
30
+ try {
31
+ const msg = JSON.parse(raw.toString()) as WsMessage
32
+ this.handleAdminMessage(ws, msg)
33
+ } catch (e) {
34
+ // ignore malformed admin messages
35
+ }
36
+ })
37
+ }
38
+
39
+ unregisterAdminClient(ws: WebSocket): void {
40
+ this.adminClients.delete(ws)
41
+ }
42
+
43
+ setAutoAcceptApiKey(value: boolean): void {
44
+ this.autoAcceptApiKey = value
45
+ }
46
+
47
+ getAutoAcceptApiKey(): boolean {
48
+ return this.autoAcceptApiKey
49
+ }
50
+
51
+ /** Broadcast event to all admin UI clients */
52
+ broadcastAdmin(event: string, payload: any): void {
53
+ const data = JSON.stringify({ event, payload })
54
+ for (const ws of this.adminClients) {
55
+ if (ws.readyState === WebSocket.OPEN) ws.send(data)
56
+ }
57
+ }
58
+
59
+ /** Resolve a pending request from admin action */
60
+ resolvePending(id: string, response: any): void {
61
+ const pending = this.pendingRequests.get(id)
62
+ if (pending) {
63
+ clearTimeout(pending.timeout)
64
+ pending.resolve(response)
65
+ this.pendingRequests.delete(id)
66
+ }
67
+ }
68
+
69
+ rejectPending(id: string, error: string): void {
70
+ const pending = this.pendingRequests.get(id)
71
+ if (pending) {
72
+ clearTimeout(pending.timeout)
73
+ pending.reject(error)
74
+ this.pendingRequests.delete(id)
75
+ }
76
+ }
77
+
78
+ private onConnection(ws: WebSocket, req: IncomingMessage): void {
79
+ // Admin UI connects to /ws/admin
80
+ if (req.url?.startsWith('/ws/admin')) {
81
+ this.registerAdminClient(ws)
82
+ ws.send(JSON.stringify({ event: 'admin_connected', payload: { ok: true } }))
83
+ return
84
+ }
85
+
86
+ // Client SDK connects to /ws
87
+ this.setupClient(ws)
88
+ }
89
+
90
+ private setupClient(ws: WebSocket): void {
91
+ let authState: { projectName: string; fingerprint: string } | null = null
92
+ let authenticated = false
93
+
94
+ const send = (msg: WsMessage) => {
95
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
96
+ }
97
+
98
+ ws.on('message', async (raw) => {
99
+ let msg: WsMessage | null = null
100
+ try {
101
+ msg = JSON.parse(raw.toString()) as WsMessage
102
+ } catch {
103
+ send({ type: 'get_response', payload: { error: 'Malformed request', code: ErrorCode.SERVER_INTERNAL_ERROR } })
104
+ return
105
+ }
106
+
107
+ if (!this.pk.isReady() && msg.type !== 'auth') {
108
+ send({ type: (msg.type === 'get' ? 'get_response' : 'set_response') as any, payload: { error: ErrorMessages.SERVER_PK_NOT_AVAILABLE, code: ErrorCode.SERVER_PK_NOT_AVAILABLE } })
109
+ return
110
+ }
111
+
112
+ switch (msg.type) {
113
+ case 'auth': {
114
+ const { projectName, apiKey, fingerprint } = msg.payload as any
115
+ const project = await this.db.getProject(projectName)
116
+ if (!project) {
117
+ send({ type: 'auth_response', payload: { success: false, whitelisted: false, error: ErrorMessages.CLIENT_AUTH_INVALID_PROJECT_NAME, code: ErrorCode.CLIENT_AUTH_INVALID_PROJECT_NAME } })
118
+ return
119
+ }
120
+
121
+ // API key is optional - if provided, validate it
122
+ if (apiKey && apiKey.trim() !== '') {
123
+ const apiKeyDoc = await this.db.getApiKey(project.id, apiKey)
124
+ if (!apiKeyDoc) {
125
+ send({ type: 'auth_response', payload: { success: false, whitelisted: false, error: ErrorMessages.CLIENT_AUTH_INVALID_API_KEY, code: ErrorCode.CLIENT_AUTH_INVALID_API_KEY } })
126
+ return
127
+ }
128
+ }
129
+
130
+ let wl = await this.db.getWhitelistEntry(project.id, fingerprint)
131
+ const wlStatus = wl?.status || null
132
+
133
+ if (!wl) {
134
+ // Create a pending whitelist entry and notify admin
135
+ await this.db.createWhitelistEntry(project.id, fingerprint, 'pending')
136
+ this.broadcastAdmin('whitelist_pending', { fingerprint, projectName: project.name, projectId: project.id })
137
+ }
138
+
139
+ authState = { projectName, fingerprint }
140
+ authenticated = true
141
+ send({ type: 'auth_response', payload: { success: true, whitelisted: wlStatus === 'approved', status: wlStatus || 'pending' } })
142
+ break
143
+ }
144
+
145
+ case 'get': {
146
+ if (!authenticated) {
147
+ send({ type: 'get_response', payload: { error: 'Not authenticated', code: ErrorCode.CLIENT_AUTH_INVALID_API_KEY } })
148
+ return
149
+ }
150
+ const { key } = msg.payload as any
151
+ const project = await this.db.getProject(authState!.projectName)
152
+ if (!project) return send({ type: 'get_response', payload: { error: ErrorMessages.CLIENT_AUTH_INVALID_PROJECT_NAME, code: ErrorCode.CLIENT_AUTH_INVALID_PROJECT_NAME } })
153
+
154
+ const wl = await this.db.getWhitelistEntry(project.id, authState!.fingerprint)
155
+ if (wl?.status !== 'approved') {
156
+ return send({ type: 'get_response', payload: { error: ErrorMessages.CLIENT_AUTH_NOT_WHITELISTED, code: ErrorCode.CLIENT_AUTH_NOT_WHITELISTED } })
157
+ }
158
+
159
+ const env = await this.db.getEnv(project.id, key)
160
+ if (!env) return send({ type: 'get_response', payload: { error: `Key not found: ${key}`, code: ErrorCode.SERVER_INTERNAL_ERROR } })
161
+
162
+ // Block api-mode-only envs for CLI channel
163
+ if (env.apiModeOnly) {
164
+ return send({ type: 'get_response', payload: { error: ErrorMessages.ENV_API_MODE_ONLY, code: ErrorCode.ENV_API_MODE_ONLY } })
165
+ }
166
+
167
+ const decrypted = this.pk.decrypt(env.encryptedValue, `${project.id}:${key}`)
168
+ send({ type: 'get_response', payload: { key, value: decrypted } })
169
+ break
170
+ }
171
+
172
+ case 'set': {
173
+ if (!authenticated) {
174
+ send({ type: 'set_response', payload: { error: 'Not authenticated', code: ErrorCode.CLIENT_AUTH_INVALID_API_KEY } })
175
+ return
176
+ }
177
+ const { key, value } = msg.payload as any
178
+ const project = await this.db.getProject(authState!.projectName)
179
+ if (!project) return send({ type: 'set_response', payload: { error: ErrorMessages.CLIENT_AUTH_INVALID_PROJECT_NAME, code: ErrorCode.CLIENT_AUTH_INVALID_PROJECT_NAME } })
180
+
181
+ const wl = await this.db.getWhitelistEntry(project.id, authState!.fingerprint)
182
+ if (wl?.status !== 'approved') {
183
+ return send({ type: 'set_response', payload: { error: ErrorMessages.CLIENT_AUTH_NOT_WHITELISTED, code: ErrorCode.CLIENT_AUTH_NOT_WHITELISTED } })
184
+ }
185
+
186
+ const existing = await this.db.getEnv(project.id, key)
187
+ const encrypted = this.pk.encrypt(value, `${project.id}:${key}`)
188
+
189
+ if (existing) {
190
+ await this.db.updateEnv(project.id, key, encrypted, existing.apiModeOnly)
191
+ } else {
192
+ await this.db.createEnv(project.id, key, encrypted, false)
193
+ }
194
+
195
+ send({ type: 'set_response', payload: { success: true } })
196
+ break
197
+ }
198
+
199
+ case 'generate_api_key': {
200
+ const { projectName } = msg.payload as any
201
+ // Notify admin
202
+ const reqId = crypto.randomUUID()
203
+ this.broadcastAdmin('apikey_gen_request', { reqId, clientFingerprint: authState?.fingerprint, projectName })
204
+
205
+ // Wait for admin response
206
+ const result = await new Promise<any>((resolve, reject) => {
207
+ const timeout = setTimeout(() => reject(new Error('Timeout waiting for admin approval')), 60_000)
208
+ this.pendingRequests.set(reqId, { resolve, reject, timeout })
209
+ })
210
+
211
+ if (result.approved) {
212
+ const project = await this.db.getProject(result.projectName || projectName)
213
+ if (project) {
214
+ const newKey = crypto.randomUUID()
215
+ const encrypted = this.pk.encrypt(newKey, `${project.id}:apikey:${newKey.slice(0, 8)}`)
216
+ await this.db.createApiKey(project.id, encrypted, newKey)
217
+ // Also auto-whitelist the fingerprint if not already approved
218
+ const wl = await this.db.getWhitelistEntry(project.id, authState!.fingerprint)
219
+ if (!wl || wl.status === 'pending') {
220
+ await this.db.createWhitelistEntry(project.id, authState!.fingerprint, 'approved')
221
+ }
222
+ send({ type: 'apikey_response', payload: { success: true, apiKey: newKey } })
223
+ } else {
224
+ send({ type: 'apikey_response', payload: { error: 'Project not found', code: ErrorCode.CLIENT_AUTH_INVALID_PROJECT_NAME } })
225
+ }
226
+ } else {
227
+ send({ type: 'apikey_response', payload: { error: 'API key generation rejected by admin', code: ErrorCode.CLIENT_AUTH_INVALID_API_KEY } })
228
+ }
229
+ break
230
+ }
231
+
232
+ case 'ping':
233
+ send({ type: 'pong' })
234
+ break
235
+
236
+ default:
237
+ send({ type: 'get_response', payload: { error: 'Unknown message type', code: ErrorCode.SERVER_INTERNAL_ERROR } })
238
+ }
239
+ })
240
+
241
+ ws.on('close', () => {
242
+ // cleanup
243
+ })
244
+ }
245
+
246
+ private handleAdminMessage(ws: WebSocket, msg: WsMessage): void {
247
+ switch (msg.type) {
248
+ case 'whitelist_response': {
249
+ const { fingerprint, approved } = msg.payload as any
250
+ // We need projectId and fingerprint to update - admin sends this via REST actually
251
+ // For simplicity, this WS channel just notifies the admin; the actual approve/reject
252
+ // is done via REST API which calls db.updateWhitelistStatus()
253
+ // We can broadcast the decision if needed
254
+ break
255
+ }
256
+ case 'apikey_response': {
257
+ const { reqId, approved, projectName } = msg.payload as any
258
+ this.resolvePending(reqId, { approved, projectName })
259
+ break
260
+ }
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "humanenv-shared",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "typescript": "^5.7.0"
12
+ }
13
+ }
@@ -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
+ }