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