@andespindola/brainlink 1.6.2 → 1.6.3
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.
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Self-contained branded auth pages for the graph web server. These are served
|
|
2
|
+
// as plain strings (inline <style> + inline <script>, no external assets, no
|
|
3
|
+
// framework) so they work identically for a global npm install. The visual
|
|
4
|
+
// identity mirrors the graph client (see client-css.ts / client-html.ts): dark
|
|
5
|
+
// theme on #08131d, Crowquill Mono with a monospace fallback, and the #5aa8ff
|
|
6
|
+
// accent used for graph nodes.
|
|
7
|
+
const authStyles = () => `:root {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
--bg: #08131d;
|
|
10
|
+
--panel: #0d1823;
|
|
11
|
+
--panel-strong: #112130;
|
|
12
|
+
--line: rgba(143, 172, 204, 0.18);
|
|
13
|
+
--text: #edf4ff;
|
|
14
|
+
--muted: #97a9bd;
|
|
15
|
+
--accent: #5aa8ff;
|
|
16
|
+
--accent-weak: rgba(90, 168, 255, 0.18);
|
|
17
|
+
--danger: #ff6b6b;
|
|
18
|
+
--success: #57d9a3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
html,
|
|
26
|
+
body {
|
|
27
|
+
width: 100%;
|
|
28
|
+
min-height: 100%;
|
|
29
|
+
margin: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
min-height: 100dvh;
|
|
38
|
+
padding: 24px;
|
|
39
|
+
color: var(--text);
|
|
40
|
+
font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
41
|
+
background:
|
|
42
|
+
linear-gradient(rgba(164, 197, 230, 0.05) 1px, transparent 1px),
|
|
43
|
+
linear-gradient(90deg, rgba(164, 197, 230, 0.05) 1px, transparent 1px),
|
|
44
|
+
radial-gradient(circle at top, rgba(43, 93, 143, 0.22), transparent 44%),
|
|
45
|
+
#08131d;
|
|
46
|
+
background-size: 28px 28px, 28px 28px, auto;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
button,
|
|
50
|
+
input {
|
|
51
|
+
font: inherit;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.auth-card {
|
|
55
|
+
width: 100%;
|
|
56
|
+
max-width: 380px;
|
|
57
|
+
padding: 32px 28px;
|
|
58
|
+
border: 1px solid var(--line);
|
|
59
|
+
border-radius: 14px;
|
|
60
|
+
background: rgba(13, 24, 35, 0.92);
|
|
61
|
+
box-shadow: 0 24px 80px rgba(1, 6, 13, 0.48);
|
|
62
|
+
backdrop-filter: blur(8px);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.auth-brand {
|
|
66
|
+
display: grid;
|
|
67
|
+
gap: 4px;
|
|
68
|
+
margin-bottom: 24px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.auth-brand strong {
|
|
72
|
+
font-size: 26px;
|
|
73
|
+
letter-spacing: 0.01em;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.auth-brand span {
|
|
77
|
+
color: var(--muted);
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.auth-form {
|
|
82
|
+
display: grid;
|
|
83
|
+
gap: 16px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.auth-field {
|
|
87
|
+
display: grid;
|
|
88
|
+
gap: 7px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.auth-field label {
|
|
92
|
+
color: var(--muted);
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
letter-spacing: 0.02em;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.auth-field input {
|
|
98
|
+
width: 100%;
|
|
99
|
+
height: 42px;
|
|
100
|
+
padding: 0 14px;
|
|
101
|
+
border: 1px solid var(--line);
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
outline: none;
|
|
104
|
+
background: rgba(12, 24, 36, 0.94);
|
|
105
|
+
color: var(--text);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.auth-field input:focus {
|
|
109
|
+
border-color: var(--accent);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.auth-submit {
|
|
113
|
+
height: 44px;
|
|
114
|
+
margin-top: 4px;
|
|
115
|
+
border: 1px solid var(--accent);
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
background: var(--accent-weak);
|
|
118
|
+
color: var(--text);
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
transition: background 120ms ease, opacity 120ms ease;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.auth-submit:hover:not(:disabled),
|
|
124
|
+
.auth-submit:focus:not(:disabled) {
|
|
125
|
+
background: rgba(90, 168, 255, 0.3);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.auth-submit:disabled {
|
|
129
|
+
cursor: progress;
|
|
130
|
+
opacity: 0.6;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.auth-message {
|
|
134
|
+
min-height: 18px;
|
|
135
|
+
margin: 0;
|
|
136
|
+
font-size: 12px;
|
|
137
|
+
line-height: 1.4;
|
|
138
|
+
overflow-wrap: anywhere;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.auth-message[hidden] {
|
|
142
|
+
display: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.auth-message.is-error {
|
|
146
|
+
color: var(--danger);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.auth-message.is-success {
|
|
150
|
+
color: var(--success);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.auth-back {
|
|
154
|
+
display: inline-block;
|
|
155
|
+
margin-top: 20px;
|
|
156
|
+
color: var(--muted);
|
|
157
|
+
font-size: 12px;
|
|
158
|
+
text-decoration: none;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.auth-back:hover,
|
|
162
|
+
.auth-back:focus {
|
|
163
|
+
color: var(--accent);
|
|
164
|
+
}`;
|
|
165
|
+
const documentShell = (title, body, script) => `<!DOCTYPE html>
|
|
166
|
+
<html lang="en">
|
|
167
|
+
<head>
|
|
168
|
+
<meta charset="utf-8" />
|
|
169
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
170
|
+
<title>${title}</title>
|
|
171
|
+
<style>${authStyles()}</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
${body}
|
|
175
|
+
<script>${script}</script>
|
|
176
|
+
</body>
|
|
177
|
+
</html>`;
|
|
178
|
+
const loginScript = () => `(function () {
|
|
179
|
+
var form = document.getElementById('loginForm');
|
|
180
|
+
var button = document.getElementById('submit');
|
|
181
|
+
var error = document.getElementById('error');
|
|
182
|
+
|
|
183
|
+
function showError(text) {
|
|
184
|
+
error.textContent = text;
|
|
185
|
+
error.hidden = false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function clearError() {
|
|
189
|
+
error.textContent = '';
|
|
190
|
+
error.hidden = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
form.addEventListener('submit', function (event) {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
clearError();
|
|
196
|
+
var user = document.getElementById('user').value;
|
|
197
|
+
var password = document.getElementById('password').value;
|
|
198
|
+
button.disabled = true;
|
|
199
|
+
fetch('/api/login', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ user: user, password: password })
|
|
203
|
+
})
|
|
204
|
+
.then(function (response) {
|
|
205
|
+
if (response.ok) {
|
|
206
|
+
window.location.href = '/';
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (response.status === 401) {
|
|
210
|
+
showError('Invalid username or password');
|
|
211
|
+
} else {
|
|
212
|
+
showError('Something went wrong. Please try again.');
|
|
213
|
+
}
|
|
214
|
+
button.disabled = false;
|
|
215
|
+
})
|
|
216
|
+
.catch(function () {
|
|
217
|
+
showError('Something went wrong. Please try again.');
|
|
218
|
+
button.disabled = false;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
})();`;
|
|
222
|
+
const changePasswordScript = () => `(function () {
|
|
223
|
+
var form = document.getElementById('changeForm');
|
|
224
|
+
var button = document.getElementById('submit');
|
|
225
|
+
var message = document.getElementById('message');
|
|
226
|
+
|
|
227
|
+
function show(text, kind) {
|
|
228
|
+
message.textContent = text;
|
|
229
|
+
message.className = 'auth-message ' + (kind === 'success' ? 'is-success' : 'is-error');
|
|
230
|
+
message.hidden = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clearMessage() {
|
|
234
|
+
message.textContent = '';
|
|
235
|
+
message.className = 'auth-message';
|
|
236
|
+
message.hidden = true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
var reasons = {
|
|
240
|
+
'invalid-current-password': 'Current password is incorrect',
|
|
241
|
+
'weak-password': 'New password must be at least 8 characters'
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
form.addEventListener('submit', function (event) {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
clearMessage();
|
|
247
|
+
var current = document.getElementById('current').value;
|
|
248
|
+
var next = document.getElementById('next').value;
|
|
249
|
+
var confirm = document.getElementById('confirm').value;
|
|
250
|
+
|
|
251
|
+
if (next !== confirm) {
|
|
252
|
+
show('New passwords do not match', 'error');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (next.length < 8) {
|
|
256
|
+
show('New password must be at least 8 characters', 'error');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
button.disabled = true;
|
|
261
|
+
fetch('/api/change-password', {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: { 'Content-Type': 'application/json' },
|
|
264
|
+
body: JSON.stringify({ currentPassword: current, newPassword: next })
|
|
265
|
+
})
|
|
266
|
+
.then(function (response) {
|
|
267
|
+
if (response.ok) {
|
|
268
|
+
show('Password updated. Redirecting…', 'success');
|
|
269
|
+
setTimeout(function () {
|
|
270
|
+
window.location.href = '/';
|
|
271
|
+
}, 1000);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
return response
|
|
275
|
+
.json()
|
|
276
|
+
.catch(function () {
|
|
277
|
+
return {};
|
|
278
|
+
})
|
|
279
|
+
.then(function (data) {
|
|
280
|
+
var reason = data && data.reason ? data.reason : '';
|
|
281
|
+
show(reasons[reason] || 'Unable to update password. Please try again.', 'error');
|
|
282
|
+
button.disabled = false;
|
|
283
|
+
});
|
|
284
|
+
})
|
|
285
|
+
.catch(function () {
|
|
286
|
+
show('Something went wrong. Please try again.', 'error');
|
|
287
|
+
button.disabled = false;
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
})();`;
|
|
291
|
+
export const createLoginPageHtml = () => documentShell('Sign in · Brainlink', ` <main class="auth-card">
|
|
292
|
+
<div class="auth-brand">
|
|
293
|
+
<strong>Brainlink</strong>
|
|
294
|
+
<span>Sign in to your knowledge graph</span>
|
|
295
|
+
</div>
|
|
296
|
+
<form id="loginForm" class="auth-form" novalidate>
|
|
297
|
+
<div class="auth-field">
|
|
298
|
+
<label for="user">Username</label>
|
|
299
|
+
<input id="user" name="user" type="text" autocomplete="username" autofocus required />
|
|
300
|
+
</div>
|
|
301
|
+
<div class="auth-field">
|
|
302
|
+
<label for="password">Password</label>
|
|
303
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
304
|
+
</div>
|
|
305
|
+
<p id="error" class="auth-message is-error" role="alert" aria-live="assertive" hidden></p>
|
|
306
|
+
<button id="submit" class="auth-submit" type="submit">Sign in</button>
|
|
307
|
+
</form>
|
|
308
|
+
</main>`, loginScript());
|
|
309
|
+
export const createChangePasswordPageHtml = () => documentShell('Change password · Brainlink', ` <main class="auth-card">
|
|
310
|
+
<div class="auth-brand">
|
|
311
|
+
<strong>Brainlink</strong>
|
|
312
|
+
<span>Change your password</span>
|
|
313
|
+
</div>
|
|
314
|
+
<form id="changeForm" class="auth-form" novalidate>
|
|
315
|
+
<div class="auth-field">
|
|
316
|
+
<label for="current">Current password</label>
|
|
317
|
+
<input id="current" name="current" type="password" autocomplete="current-password" autofocus required />
|
|
318
|
+
</div>
|
|
319
|
+
<div class="auth-field">
|
|
320
|
+
<label for="next">New password</label>
|
|
321
|
+
<input id="next" name="next" type="password" autocomplete="new-password" minlength="8" required />
|
|
322
|
+
</div>
|
|
323
|
+
<div class="auth-field">
|
|
324
|
+
<label for="confirm">Confirm new password</label>
|
|
325
|
+
<input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" required />
|
|
326
|
+
</div>
|
|
327
|
+
<p id="message" class="auth-message" role="status" aria-live="polite" hidden></p>
|
|
328
|
+
<button id="submit" class="auth-submit" type="submit">Update password</button>
|
|
329
|
+
</form>
|
|
330
|
+
<a class="auth-back" href="/">← Back</a>
|
|
331
|
+
</main>`, changePasswordScript());
|
|
@@ -22,6 +22,8 @@ import { createClientHtml } from '../frontend/client-html.js';
|
|
|
22
22
|
import { createClientJs } from '../frontend/client-js.js';
|
|
23
23
|
import { createClientWorkerJs } from '../frontend/client-worker-js.js';
|
|
24
24
|
import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
|
|
25
|
+
import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/login-page.js';
|
|
26
|
+
import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
|
|
25
27
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
26
28
|
import { parseMultipartForm } from './multipart.js';
|
|
27
29
|
const readRuntimeDefaults = async (url) => {
|
|
@@ -60,6 +62,89 @@ const createResponse = (body, statusCode = 200, contentType = 'text/plain; chars
|
|
|
60
62
|
'cache-control': 'no-store'
|
|
61
63
|
}
|
|
62
64
|
});
|
|
65
|
+
const withHeaders = (response, extra) => ({
|
|
66
|
+
...response,
|
|
67
|
+
headers: { ...response.headers, ...extra }
|
|
68
|
+
});
|
|
69
|
+
const redirectResponse = (location) => ({
|
|
70
|
+
body: '',
|
|
71
|
+
statusCode: 302,
|
|
72
|
+
headers: { location, 'cache-control': 'no-store' }
|
|
73
|
+
});
|
|
74
|
+
const readStringField = (value, key) => {
|
|
75
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
76
|
+
const field = value[key];
|
|
77
|
+
return typeof field === 'string' ? field : '';
|
|
78
|
+
}
|
|
79
|
+
return '';
|
|
80
|
+
};
|
|
81
|
+
const firstHeaderValue = (value) => Array.isArray(value) ? value[0] : value;
|
|
82
|
+
// The exposed graph server sits behind a TLS-terminating proxy, so trust the
|
|
83
|
+
// forwarded scheme to decide whether the session cookie may carry `Secure`.
|
|
84
|
+
const requestIsSecure = (request) => {
|
|
85
|
+
const proto = firstHeaderValue(request.headers['x-forwarded-proto']);
|
|
86
|
+
return typeof proto === 'string' && proto.split(',')[0]?.trim() === 'https';
|
|
87
|
+
};
|
|
88
|
+
const isHtmlNavigation = (request, url) => {
|
|
89
|
+
if (url.pathname.startsWith('/api/')) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const accept = firstHeaderValue(request.headers.accept);
|
|
93
|
+
return typeof accept !== 'string' || accept.includes('text/html') || accept.includes('*/*');
|
|
94
|
+
};
|
|
95
|
+
const sessionTtlMs = 7 * 24 * 60 * 60 * 1000;
|
|
96
|
+
// Authentication gate for the exposed graph server. Returns a fully-formed
|
|
97
|
+
// response when it handles the request (login page/submit, logout, redirect,
|
|
98
|
+
// 401, change-password), or null when the caller is authenticated and normal
|
|
99
|
+
// routing should continue. Local mode never invokes this.
|
|
100
|
+
const runAuthGate = async (request, url) => {
|
|
101
|
+
const auth = await resolveWebAuth();
|
|
102
|
+
if (!auth) {
|
|
103
|
+
return createResponse(createJsonResponse({ error: 'Web authentication is not configured. Set BRAINLINK_WEB_USER and BRAINLINK_WEB_PASSWORD.' }), 503, contentTypes['.json']);
|
|
104
|
+
}
|
|
105
|
+
if (isReadMethod(request) && url.pathname === '/healthz') {
|
|
106
|
+
return createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json']);
|
|
107
|
+
}
|
|
108
|
+
if (isReadMethod(request) && url.pathname === '/login') {
|
|
109
|
+
return createResponse(createLoginPageHtml(), 200, contentTypes['.html']);
|
|
110
|
+
}
|
|
111
|
+
if (request.method === 'POST' && url.pathname === '/api/login') {
|
|
112
|
+
const body = await readJsonBody(request);
|
|
113
|
+
const user = readStringField(body, 'user');
|
|
114
|
+
const password = readStringField(body, 'password');
|
|
115
|
+
if (user === auth.user && verifyWebPassword(password, auth.passwordHash)) {
|
|
116
|
+
const token = createSessionToken(auth, sessionTtlMs, Date.now());
|
|
117
|
+
return withHeaders(createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json']), {
|
|
118
|
+
'set-cookie': buildSessionSetCookie(token, { secure: requestIsSecure(request), ttlMs: sessionTtlMs })
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return createResponse(createJsonResponse({ error: 'Invalid username or password' }), 401, contentTypes['.json']);
|
|
122
|
+
}
|
|
123
|
+
if (request.method === 'POST' && url.pathname === '/api/logout') {
|
|
124
|
+
return withHeaders(createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json']), {
|
|
125
|
+
'set-cookie': buildClearSessionCookie()
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const cookies = parseCookies(firstHeaderValue(request.headers.cookie));
|
|
129
|
+
const token = cookies[SESSION_COOKIE_NAME] ?? '';
|
|
130
|
+
const authenticated = token.length > 0 && verifySessionToken(token, auth, Date.now());
|
|
131
|
+
if (!authenticated) {
|
|
132
|
+
return isHtmlNavigation(request, url)
|
|
133
|
+
? redirectResponse('/login')
|
|
134
|
+
: createResponse(createJsonResponse({ error: 'Unauthorized' }), 401, contentTypes['.json']);
|
|
135
|
+
}
|
|
136
|
+
if (isReadMethod(request) && url.pathname === '/change-password') {
|
|
137
|
+
return createResponse(createChangePasswordPageHtml(), 200, contentTypes['.html']);
|
|
138
|
+
}
|
|
139
|
+
if (request.method === 'POST' && url.pathname === '/api/change-password') {
|
|
140
|
+
const body = await readJsonBody(request);
|
|
141
|
+
const result = await changeWebPassword(readStringField(body, 'currentPassword'), readStringField(body, 'newPassword'));
|
|
142
|
+
return result.ok
|
|
143
|
+
? createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json'])
|
|
144
|
+
: createResponse(createJsonResponse({ error: result.reason ?? 'change-failed', reason: result.reason }), 400, contentTypes['.json']);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
};
|
|
63
148
|
const normalizeHeaderToken = (value) => value?.trim().replace(/^"|"$/g, '') ?? '';
|
|
64
149
|
const decodeEntityTag = (candidate) => {
|
|
65
150
|
const token = normalizeHeaderToken(candidate);
|
|
@@ -266,7 +351,13 @@ const storeGraphLayoutBody = (signature, body) => {
|
|
|
266
351
|
graphLayoutBodyCache.delete(oldest);
|
|
267
352
|
}
|
|
268
353
|
};
|
|
269
|
-
export const route = async (request, url, vaultPath) => {
|
|
354
|
+
export const route = async (request, url, vaultPath, options = {}) => {
|
|
355
|
+
if (options.requireAuth) {
|
|
356
|
+
const gated = await runAuthGate(request, url);
|
|
357
|
+
if (gated) {
|
|
358
|
+
return gated;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
270
361
|
if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
271
362
|
return createResponse(readClientHtml(), 200, contentTypes['.html']);
|
|
272
363
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { getBrainlinkHomePath } from '../../infrastructure/paths.js';
|
|
5
|
+
const scryptKeyLength = 32;
|
|
6
|
+
const scryptSaltLength = 16;
|
|
7
|
+
const minPasswordLength = 8;
|
|
8
|
+
export const SESSION_COOKIE_NAME = 'brainlink_session';
|
|
9
|
+
export const hashWebPassword = (password) => {
|
|
10
|
+
const salt = randomBytes(scryptSaltLength);
|
|
11
|
+
const key = scryptSync(password, salt, scryptKeyLength);
|
|
12
|
+
return `scrypt$${salt.toString('base64')}$${key.toString('base64')}`;
|
|
13
|
+
};
|
|
14
|
+
export const verifyWebPassword = (password, stored) => {
|
|
15
|
+
try {
|
|
16
|
+
const parts = stored.split('$');
|
|
17
|
+
if (parts.length !== 3 || parts[0] !== 'scrypt') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const salt = Buffer.from(parts[1], 'base64');
|
|
21
|
+
const expected = Buffer.from(parts[2], 'base64');
|
|
22
|
+
if (salt.length === 0 || expected.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const actual = scryptSync(password, salt, expected.length);
|
|
26
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
export const getWebAuthStorePath = () => join(getBrainlinkHomePath(), 'web-auth.json');
|
|
33
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
|
|
34
|
+
const parseWebAuthState = (value) => {
|
|
35
|
+
if (typeof value !== 'object' || value === null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const candidate = value;
|
|
39
|
+
if (!isNonEmptyString(candidate.user) ||
|
|
40
|
+
!isNonEmptyString(candidate.passwordHash) ||
|
|
41
|
+
!isNonEmptyString(candidate.sessionSecret)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
user: candidate.user,
|
|
46
|
+
passwordHash: candidate.passwordHash,
|
|
47
|
+
sessionSecret: candidate.sessionSecret
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export const loadStoredWebAuth = async () => {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(getWebAuthStorePath(), 'utf8');
|
|
53
|
+
return parseWebAuthState(JSON.parse(raw));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
export const persistWebAuth = async (state) => {
|
|
60
|
+
const storePath = getWebAuthStorePath();
|
|
61
|
+
await mkdir(dirname(storePath), { recursive: true, mode: 0o700 });
|
|
62
|
+
await writeFile(storePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
63
|
+
};
|
|
64
|
+
const deriveSessionSecret = (user, passwordHash) => createHash('sha256').update(`${user}:${passwordHash}`).digest('hex');
|
|
65
|
+
export const seedWebAuthFromEnv = () => {
|
|
66
|
+
const user = process.env.BRAINLINK_WEB_USER?.trim();
|
|
67
|
+
if (!isNonEmptyString(user)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const configuredHash = process.env.BRAINLINK_WEB_PASSWORD_HASH?.trim();
|
|
71
|
+
const plaintext = process.env.BRAINLINK_WEB_PASSWORD;
|
|
72
|
+
const passwordHash = isNonEmptyString(configuredHash)
|
|
73
|
+
? configuredHash
|
|
74
|
+
: isNonEmptyString(plaintext)
|
|
75
|
+
? hashWebPassword(plaintext)
|
|
76
|
+
: null;
|
|
77
|
+
if (!isNonEmptyString(passwordHash)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const configuredSecret = process.env.BRAINLINK_WEB_SESSION_SECRET?.trim();
|
|
81
|
+
// Derive the session secret from STABLE material — a fixed configured hash or
|
|
82
|
+
// the fixed plaintext password — never the freshly-salted hash, which changes
|
|
83
|
+
// on every call and would invalidate sessions between requests. Once the
|
|
84
|
+
// password is changed the persisted store becomes the source of truth.
|
|
85
|
+
const secretMaterial = isNonEmptyString(configuredHash) ? configuredHash : plaintext;
|
|
86
|
+
const sessionSecret = isNonEmptyString(configuredSecret)
|
|
87
|
+
? configuredSecret
|
|
88
|
+
: deriveSessionSecret(user, secretMaterial);
|
|
89
|
+
return { user, passwordHash, sessionSecret };
|
|
90
|
+
};
|
|
91
|
+
export const resolveWebAuth = async () => {
|
|
92
|
+
const stored = await loadStoredWebAuth();
|
|
93
|
+
return stored ?? seedWebAuthFromEnv();
|
|
94
|
+
};
|
|
95
|
+
export const changeWebPassword = async (currentPassword, nextPassword) => {
|
|
96
|
+
const state = await resolveWebAuth();
|
|
97
|
+
if (state === null) {
|
|
98
|
+
return { ok: false, reason: 'auth-not-configured' };
|
|
99
|
+
}
|
|
100
|
+
if (!verifyWebPassword(currentPassword, state.passwordHash)) {
|
|
101
|
+
return { ok: false, reason: 'invalid-current-password' };
|
|
102
|
+
}
|
|
103
|
+
if (nextPassword.length < minPasswordLength) {
|
|
104
|
+
return { ok: false, reason: 'weak-password' };
|
|
105
|
+
}
|
|
106
|
+
await persistWebAuth({ ...state, passwordHash: hashWebPassword(nextPassword) });
|
|
107
|
+
return { ok: true };
|
|
108
|
+
};
|
|
109
|
+
export const createSessionToken = (state, ttlMs, nowMs) => {
|
|
110
|
+
const payload = `${state.user}.${nowMs + ttlMs}`;
|
|
111
|
+
const signature = createHmac('sha256', state.sessionSecret).update(payload).digest('base64url');
|
|
112
|
+
return `${Buffer.from(payload).toString('base64url')}.${signature}`;
|
|
113
|
+
};
|
|
114
|
+
export const verifySessionToken = (token, state, nowMs) => {
|
|
115
|
+
try {
|
|
116
|
+
const separatorIndex = token.lastIndexOf('.');
|
|
117
|
+
if (separatorIndex <= 0) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const payloadB64 = token.slice(0, separatorIndex);
|
|
121
|
+
const signature = token.slice(separatorIndex + 1);
|
|
122
|
+
const payload = Buffer.from(payloadB64, 'base64url').toString('utf8');
|
|
123
|
+
const expectedSignature = createHmac('sha256', state.sessionSecret)
|
|
124
|
+
.update(payload)
|
|
125
|
+
.digest('base64url');
|
|
126
|
+
const actual = Buffer.from(signature, 'utf8');
|
|
127
|
+
const expected = Buffer.from(expectedSignature, 'utf8');
|
|
128
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const expiryIndex = payload.lastIndexOf('.');
|
|
132
|
+
if (expiryIndex <= 0) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const user = payload.slice(0, expiryIndex);
|
|
136
|
+
const expiry = Number.parseInt(payload.slice(expiryIndex + 1), 10);
|
|
137
|
+
if (!Number.isFinite(expiry) || user !== state.user) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return nowMs < expiry;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
export const buildSessionSetCookie = (token, options) => {
|
|
147
|
+
const ttlSeconds = Math.floor(options.ttlMs / 1000);
|
|
148
|
+
const base = `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttlSeconds}`;
|
|
149
|
+
return options.secure ? `${base}; Secure` : base;
|
|
150
|
+
};
|
|
151
|
+
export const buildClearSessionCookie = () => `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
152
|
+
export const parseCookies = (header) => {
|
|
153
|
+
if (!isNonEmptyString(header)) {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
return header.split(';').reduce((cookies, pair) => {
|
|
157
|
+
const separatorIndex = pair.indexOf('=');
|
|
158
|
+
if (separatorIndex <= 0) {
|
|
159
|
+
return cookies;
|
|
160
|
+
}
|
|
161
|
+
const key = pair.slice(0, separatorIndex).trim();
|
|
162
|
+
const value = pair.slice(separatorIndex + 1).trim();
|
|
163
|
+
if (key.length === 0) {
|
|
164
|
+
return cookies;
|
|
165
|
+
}
|
|
166
|
+
return { ...cookies, [key]: value };
|
|
167
|
+
}, {});
|
|
168
|
+
};
|
|
@@ -74,7 +74,12 @@ const maybeCompressResponse = (requestHeaders, statusCode, headers, body) => {
|
|
|
74
74
|
return { headers: withVary, body };
|
|
75
75
|
};
|
|
76
76
|
export const startServer = async (input) => {
|
|
77
|
-
|
|
77
|
+
// Local mode stays loopback-only. Exposed mode is meant to sit behind a
|
|
78
|
+
// reverse proxy with TLS and gates every route behind the branded login, so
|
|
79
|
+
// binding to a non-loopback host is allowed there.
|
|
80
|
+
if (!input.expose) {
|
|
81
|
+
assertLoopbackHost(input.host);
|
|
82
|
+
}
|
|
78
83
|
if (input.shouldIndex) {
|
|
79
84
|
await indexVault(input.vaultPath);
|
|
80
85
|
}
|
|
@@ -86,7 +91,7 @@ export const startServer = async (input) => {
|
|
|
86
91
|
: null;
|
|
87
92
|
const server = createServer((request, response) => {
|
|
88
93
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
|
|
89
|
-
route(request, url, input.vaultPath)
|
|
94
|
+
route(request, url, input.vaultPath, { requireAuth: input.expose === true })
|
|
90
95
|
.then((result) => {
|
|
91
96
|
const encoded = maybeCompressResponse(request.headers, result.statusCode, result.headers, result.body);
|
|
92
97
|
response.writeHead(result.statusCode, encoded.headers);
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { platform, tmpdir } from 'node:os';
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { startServer } from '../../../application/start-server.js';
|
|
6
|
+
import { resolveWebAuth } from '../../../application/server/web-auth.js';
|
|
6
7
|
import { isBucketVaultPath } from '../../../infrastructure/file-system-vault.js';
|
|
7
8
|
import { startRemoteMcpServer } from '../../../mcp/http-server.js';
|
|
8
9
|
import { parsePositiveInteger, print, resolveOptions } from '../../runtime.js';
|
|
@@ -489,32 +490,43 @@ export const registerServerCommands = (program) => {
|
|
|
489
490
|
.option('--no-open', 'do not open the graph UI automatically')
|
|
490
491
|
.option('-w, --watch', 'watch markdown files and reindex on changes', true)
|
|
491
492
|
.option('--no-watch', 'disable markdown file watching')
|
|
493
|
+
.option('--expose', 'expose an authenticated remote graph UI (branded login) instead of the local loopback UI')
|
|
492
494
|
.option('--json', 'print machine-readable JSON')
|
|
493
|
-
.description('start a
|
|
495
|
+
.description('start a web UI for the knowledge graph (local by default, or authenticated remote with --expose)')
|
|
494
496
|
.action(async (options) => {
|
|
495
497
|
const resolved = await resolveOptions(options);
|
|
498
|
+
const expose = options.expose === true;
|
|
499
|
+
// Exposed mode must never serve the vault unauthenticated: require credentials
|
|
500
|
+
// (persistent web-auth store or BRAINLINK_WEB_USER/PASSWORD env) before binding.
|
|
501
|
+
if (expose && (await resolveWebAuth()) === null) {
|
|
502
|
+
throw new Error('Refusing to expose the graph UI without authentication. Configure credentials first (BRAINLINK_WEB_USER + BRAINLINK_WEB_PASSWORD, or a persisted web-auth store).');
|
|
503
|
+
}
|
|
496
504
|
const shouldWatch = options.watch !== false && !isBucketVaultPath(resolved.vault);
|
|
497
505
|
const server = await startServer({
|
|
498
506
|
vaultPath: resolved.vault,
|
|
499
507
|
host: options.host ?? resolved.config.host,
|
|
500
508
|
port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
|
|
501
509
|
shouldIndex: options.index,
|
|
502
|
-
shouldWatch
|
|
510
|
+
shouldWatch,
|
|
511
|
+
expose
|
|
503
512
|
});
|
|
504
|
-
|
|
513
|
+
// Never auto-open a browser in exposed mode (it runs headless behind a proxy).
|
|
514
|
+
const openResult = options.open !== false && !expose ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
|
|
505
515
|
print(options.json, {
|
|
506
516
|
url: server.url,
|
|
517
|
+
mode: expose ? 'exposed' : 'local',
|
|
518
|
+
auth: expose ? 'branded-login' : 'none',
|
|
507
519
|
watch: shouldWatch,
|
|
508
520
|
readonly: true,
|
|
509
521
|
openedUi: openResult.opened,
|
|
510
522
|
openMode: openResult.mode
|
|
511
|
-
}, () => `Brainlink graph server running at ${server.url} (${shouldWatch ? 'watching for changes' : 'watch disabled'})${openResult.opened
|
|
523
|
+
}, () => `Brainlink graph server running at ${server.url} (${expose ? 'exposed, branded login' : 'local'}${shouldWatch ? ', watching for changes' : ', watch disabled'})${openResult.opened
|
|
512
524
|
? openResult.mode === 'native-gui'
|
|
513
525
|
? ' (opened in native desktop GUI)'
|
|
514
526
|
: openResult.mode === 'app-window'
|
|
515
527
|
? ' (opened in dedicated app window)'
|
|
516
528
|
: ' (opened in browser)'
|
|
517
|
-
: options.open === false
|
|
529
|
+
: options.open === false || expose
|
|
518
530
|
? ' (auto-open disabled)'
|
|
519
531
|
: ''}`);
|
|
520
532
|
});
|
package/package.json
CHANGED