@andespindola/brainlink 1.6.2 → 1.6.4
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/dist/application/frontend/login-page.js +331 -0
- package/dist/application/server/routes.js +92 -1
- package/dist/application/server/web-auth.js +168 -0
- package/dist/application/start-server.js +7 -2
- package/dist/application/vault-bundle.js +72 -0
- package/dist/application/vault-snapshot.js +11 -5
- package/dist/cli/commands/vault-sync-commands.js +32 -7
- package/dist/cli/commands/write/server-commands.js +17 -5
- package/dist/infrastructure/config.js +13 -0
- package/dist/mcp/server.js +16 -1
- package/dist/mcp/tools/vault-tools.js +47 -0
- package/dist/mcp/tools.js +1 -1
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Multi-vault bundle (`.blbundle`): a single gzip-compressed envelope carrying one
|
|
2
|
+
// snapshot per vault, built on the reusable single-vault snapshot helpers. Exports
|
|
3
|
+
// all known vaults (or a chosen subset) into one movable file; import restores every
|
|
4
|
+
// bundled vault back to its recorded path or a chosen target, preserving conflicts.
|
|
5
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
7
|
+
import { basename, resolve } from 'node:path';
|
|
8
|
+
import { buildSnapshotEnvelope, applySnapshotEnvelope } from './vault-snapshot.js';
|
|
9
|
+
const bundleVersion = 1;
|
|
10
|
+
const bundleKind = 'brainlink-vault-bundle';
|
|
11
|
+
const dedupePaths = (vaultPaths) => {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const paths = [];
|
|
14
|
+
for (const vaultPath of vaultPaths) {
|
|
15
|
+
if (seen.has(vaultPath)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
seen.add(vaultPath);
|
|
19
|
+
paths.push(vaultPath);
|
|
20
|
+
}
|
|
21
|
+
return paths;
|
|
22
|
+
};
|
|
23
|
+
const parseBundle = (raw) => {
|
|
24
|
+
const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
|
|
25
|
+
if (!envelope || envelope.kind !== bundleKind || envelope.version !== bundleVersion || !Array.isArray(envelope.vaults)) {
|
|
26
|
+
throw new Error('Unrecognized .blbundle format.');
|
|
27
|
+
}
|
|
28
|
+
return envelope;
|
|
29
|
+
};
|
|
30
|
+
export const exportVaultsBundle = async (vaultPaths, outputFile) => {
|
|
31
|
+
const uniquePaths = dedupePaths(vaultPaths);
|
|
32
|
+
const vaults = await Promise.all(uniquePaths.map(async (vaultPath) => {
|
|
33
|
+
const vault = resolve(vaultPath);
|
|
34
|
+
return { vault, name: basename(vault), snapshot: await buildSnapshotEnvelope(vaultPath) };
|
|
35
|
+
}));
|
|
36
|
+
const envelope = {
|
|
37
|
+
version: bundleVersion,
|
|
38
|
+
kind: bundleKind,
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
vaults
|
|
41
|
+
};
|
|
42
|
+
const packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
43
|
+
await writeFile(outputFile, packed, { mode: 0o600 });
|
|
44
|
+
const fileCount = vaults.reduce((total, entry) => total + entry.snapshot.files.length, 0);
|
|
45
|
+
return { file: outputFile, vaultCount: vaults.length, fileCount, bytes: packed.byteLength };
|
|
46
|
+
};
|
|
47
|
+
export const listBundleVaults = async (bundleFile) => {
|
|
48
|
+
const envelope = parseBundle(await readFile(bundleFile));
|
|
49
|
+
return envelope.vaults.map((entry) => ({
|
|
50
|
+
name: entry.name,
|
|
51
|
+
vault: entry.vault,
|
|
52
|
+
fileCount: entry.snapshot.files.length
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
export const importVaultsBundle = async (bundleFile, options = {}) => {
|
|
56
|
+
const envelope = parseBundle(await readFile(bundleFile));
|
|
57
|
+
const selected = options.only
|
|
58
|
+
? envelope.vaults.filter((entry) => entry.name === options.only || entry.vault === options.only)
|
|
59
|
+
: envelope.vaults;
|
|
60
|
+
if (options.only && selected.length === 0) {
|
|
61
|
+
throw new Error(`No vault "${options.only}" in bundle.`);
|
|
62
|
+
}
|
|
63
|
+
if (options.targetVault && selected.length > 1) {
|
|
64
|
+
throw new Error('targetVault requires selecting a single vault via only.');
|
|
65
|
+
}
|
|
66
|
+
const vaults = await Promise.all(selected.map(async (entry) => {
|
|
67
|
+
const target = options.targetVault ?? entry.vault;
|
|
68
|
+
const result = await applySnapshotEnvelope(entry.snapshot, target, { skipIndex: options.skipIndex });
|
|
69
|
+
return { name: entry.name, vault: target, imported: result.imported, conflicted: result.conflicted };
|
|
70
|
+
}));
|
|
71
|
+
return { vaults };
|
|
72
|
+
};
|
|
@@ -19,7 +19,7 @@ const conflictName = (path) => {
|
|
|
19
19
|
const base = extension ? path.slice(0, -extension.length) : path;
|
|
20
20
|
return `${base}.conflict-${stamp}${extension}`;
|
|
21
21
|
};
|
|
22
|
-
export const
|
|
22
|
+
export const buildSnapshotEnvelope = async (vaultPath) => {
|
|
23
23
|
const root = await ensureVault(vaultPath);
|
|
24
24
|
const markdownFiles = (await listVaultFiles(root)).filter(isMarkdown);
|
|
25
25
|
const entries = await Promise.all(markdownFiles.map(async (absolutePath) => {
|
|
@@ -30,15 +30,18 @@ export const exportVaultSnapshot = async (vaultPath, outputFile) => {
|
|
|
30
30
|
file: { path, contentB64: content.toString('base64') }
|
|
31
31
|
};
|
|
32
32
|
}));
|
|
33
|
-
|
|
33
|
+
return {
|
|
34
34
|
version: snapshotVersion,
|
|
35
35
|
createdAt: new Date().toISOString(),
|
|
36
36
|
manifest: entries.map((entry) => entry.manifest),
|
|
37
37
|
files: entries.map((entry) => entry.file)
|
|
38
38
|
};
|
|
39
|
+
};
|
|
40
|
+
export const exportVaultSnapshot = async (vaultPath, outputFile) => {
|
|
41
|
+
const envelope = await buildSnapshotEnvelope(vaultPath);
|
|
39
42
|
const packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
40
43
|
await writeFile(outputFile, packed, { mode: 0o600 });
|
|
41
|
-
return { file: outputFile, fileCount:
|
|
44
|
+
return { file: outputFile, fileCount: envelope.files.length, bytes: packed.byteLength };
|
|
42
45
|
};
|
|
43
46
|
const parseEnvelope = (raw) => {
|
|
44
47
|
const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
|
|
@@ -47,9 +50,8 @@ const parseEnvelope = (raw) => {
|
|
|
47
50
|
}
|
|
48
51
|
return envelope;
|
|
49
52
|
};
|
|
50
|
-
export const
|
|
53
|
+
export const applySnapshotEnvelope = async (envelope, vaultPath, options = {}) => {
|
|
51
54
|
const root = await ensureVault(vaultPath);
|
|
52
|
-
const envelope = parseEnvelope(await readFile(snapshotFile));
|
|
53
55
|
const manifestByPath = new Map(envelope.manifest.map((entry) => [entry.path, entry]));
|
|
54
56
|
let imported = 0;
|
|
55
57
|
let conflicted = 0;
|
|
@@ -85,3 +87,7 @@ export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {})
|
|
|
85
87
|
const index = !options.skipIndex && imported + conflicted > 0 ? await indexVault(root) : undefined;
|
|
86
88
|
return { imported, conflicted, index };
|
|
87
89
|
};
|
|
90
|
+
export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {}) => {
|
|
91
|
+
const envelope = parseEnvelope(await readFile(snapshotFile));
|
|
92
|
+
return applySnapshotEnvelope(envelope, vaultPath, options);
|
|
93
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { cloneVault, commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
|
|
2
2
|
import { provisionVaultRepo } from '../../application/provision-vault.js';
|
|
3
3
|
import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
4
|
+
import { exportVaultsBundle, importVaultsBundle, listBundleVaults } from '../../application/vault-bundle.js';
|
|
5
|
+
import { getKnownVaults } from '../../infrastructure/config.js';
|
|
4
6
|
import { print, resolveOptions } from '../runtime.js';
|
|
5
7
|
export const registerVaultSyncCommands = (program) => {
|
|
6
8
|
program
|
|
@@ -113,25 +115,48 @@ export const registerVaultSyncCommands = (program) => {
|
|
|
113
115
|
});
|
|
114
116
|
program
|
|
115
117
|
.command('vault-export')
|
|
116
|
-
.
|
|
118
|
+
.option('-o, --output <file>', 'output file (.blvault for one vault, .blbundle with --all)')
|
|
117
119
|
.option('--vault <vault>', 'vault directory')
|
|
120
|
+
.option('--all', 'export every known vault (config vault + allowedVaults) into a single .blbundle')
|
|
118
121
|
.option('--json', 'print machine-readable JSON')
|
|
119
|
-
.description('export
|
|
122
|
+
.description('export vault Markdown to a portable snapshot: one vault (.blvault) or all vaults (--all, .blbundle)')
|
|
120
123
|
.action(async (options) => {
|
|
121
|
-
const { vault } = await resolveOptions(options);
|
|
124
|
+
const { config, vault } = await resolveOptions(options);
|
|
125
|
+
if (options.all) {
|
|
126
|
+
const vaults = getKnownVaults(config);
|
|
127
|
+
const result = await exportVaultsBundle(vaults, options.output ?? 'vaults.blbundle');
|
|
128
|
+
print(options.json, result, () => `Exported ${result.vaultCount} vaults (${result.fileCount} files) to ${result.file} (${result.bytes} bytes).`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
122
131
|
const result = await exportVaultSnapshot(vault, options.output ?? 'vault.blvault');
|
|
123
132
|
print(options.json, result, () => `Exported ${result.fileCount} files to ${result.file} (${result.bytes} bytes).`);
|
|
124
133
|
});
|
|
125
134
|
program
|
|
126
135
|
.command('vault-import')
|
|
127
|
-
.argument('<file>', 'input .blvault
|
|
128
|
-
.option('--vault <vault>', 'vault directory')
|
|
136
|
+
.argument('<file>', 'input .blvault snapshot or .blbundle')
|
|
137
|
+
.option('--vault <vault>', 'target vault directory (single snapshot, or the target for --only)')
|
|
138
|
+
.option('--only <vault>', 'when importing a bundle, restore only this vault (by name or recorded path)')
|
|
129
139
|
.option('--no-index', 'skip reindexing after import')
|
|
130
140
|
.option('--json', 'print machine-readable JSON')
|
|
131
|
-
.description('import a .blvault snapshot into
|
|
141
|
+
.description('import a .blvault snapshot into a vault, or a .blbundle (all vaults, or one via --only)')
|
|
132
142
|
.action(async (file, options) => {
|
|
143
|
+
const skipIndex = options.index === false;
|
|
144
|
+
// Auto-detect a bundle so a single command handles both formats. --only is
|
|
145
|
+
// bundle-only, so it forces the bundle path.
|
|
146
|
+
const bundleEntries = await listBundleVaults(file).catch(() => null);
|
|
147
|
+
if (bundleEntries !== null || options.only !== undefined) {
|
|
148
|
+
const result = await importVaultsBundle(file, {
|
|
149
|
+
only: options.only,
|
|
150
|
+
targetVault: options.only !== undefined ? options.vault : undefined,
|
|
151
|
+
skipIndex
|
|
152
|
+
});
|
|
153
|
+
print(options.json, result, () => result.vaults
|
|
154
|
+
.map((entry) => `${entry.name} → ${entry.vault}: imported ${entry.imported} (${entry.conflicted} conflicts)`)
|
|
155
|
+
.join('\n'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
133
158
|
const { vault } = await resolveOptions(options);
|
|
134
|
-
const result = await importVaultSnapshot(file, vault, { skipIndex
|
|
159
|
+
const result = await importVaultSnapshot(file, vault, { skipIndex });
|
|
135
160
|
print(options.json, result, () => `Imported ${result.imported} files (${result.conflicted} conflicts)${result.index ? `; reindexed ${result.index.documentCount} docs` : ''}.`);
|
|
136
161
|
});
|
|
137
162
|
};
|
|
@@ -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
|
});
|
|
@@ -248,6 +248,19 @@ const sanitizeConfig = (value) => ({
|
|
|
248
248
|
defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
|
|
249
249
|
agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
|
|
250
250
|
});
|
|
251
|
+
export const getKnownVaults = (config) => {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const vaults = [];
|
|
254
|
+
for (const vault of [config.vault, ...config.allowedVaults]) {
|
|
255
|
+
const trimmed = vault.trim();
|
|
256
|
+
if (trimmed.length === 0 || seen.has(trimmed)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
seen.add(trimmed);
|
|
260
|
+
vaults.push(trimmed);
|
|
261
|
+
}
|
|
262
|
+
return vaults;
|
|
263
|
+
};
|
|
251
264
|
export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
252
265
|
const normalizedAgent = agent?.trim().length ? sanitizeAgentId(agent) : undefined;
|
|
253
266
|
const profile = (normalizedAgent ? config.agentProfiles[normalizedAgent] : undefined) ?? config.agentProfiles['*'];
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
|
|
2
|
+
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
|
|
3
3
|
import { getRuntimeVersion } from './runtime.js';
|
|
4
4
|
import { brainlinkServerInstructions } from './server-instructions.js';
|
|
5
5
|
import { guardToolHandler } from './tool-guard.js';
|
|
@@ -206,6 +206,21 @@ export const createBrainlinkMcpServer = () => {
|
|
|
206
206
|
description: 'Import a .blvault snapshot into the vault and reindex. Checksums are verified; divergent local files are preserved as *.conflict-<ts>.md instead of being overwritten.',
|
|
207
207
|
inputSchema: vaultImportInputSchema
|
|
208
208
|
}, vaultImportTool);
|
|
209
|
+
server.registerTool('brainlink_vault_export_bundle', {
|
|
210
|
+
title: 'Export All Vaults (bundle)',
|
|
211
|
+
description: 'Write a portable .blbundle containing every known vault (config vault + allowedVaults). Each vault is a gzip-free snapshot envelope inside one gzipped bundle; the derived index is not included.',
|
|
212
|
+
inputSchema: vaultExportBundleInputSchema
|
|
213
|
+
}, vaultExportBundleTool);
|
|
214
|
+
server.registerTool('brainlink_vault_bundle_list', {
|
|
215
|
+
title: 'List Vaults in a Bundle',
|
|
216
|
+
description: 'Inspect a .blbundle and list the vaults it contains (name, recorded path, file count) without importing anything.',
|
|
217
|
+
inputSchema: vaultBundleListInputSchema
|
|
218
|
+
}, vaultBundleListTool);
|
|
219
|
+
server.registerTool('brainlink_vault_import_bundle', {
|
|
220
|
+
title: 'Import Vaults from a Bundle',
|
|
221
|
+
description: 'Import a .blbundle: restore every vault to its recorded path, or a single one via "only" (optionally into "targetVault"). Checksums are verified; divergent local files are preserved as *.conflict-<ts>.md.',
|
|
222
|
+
inputSchema: vaultImportBundleInputSchema
|
|
223
|
+
}, vaultImportBundleTool);
|
|
209
224
|
server.registerTool('brainlink_graph', {
|
|
210
225
|
title: 'Read Brainlink Graph',
|
|
211
226
|
description: 'Read indexed graph nodes and wiki-link edges. Edges include weight and priority fields so agents can rank importance and priority.',
|
|
@@ -2,7 +2,9 @@ import { z } from 'zod';
|
|
|
2
2
|
import { commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
|
|
3
3
|
import { provisionVaultRepo } from '../../application/provision-vault.js';
|
|
4
4
|
import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
5
|
+
import { exportVaultsBundle, importVaultsBundle, listBundleVaults } from '../../application/vault-bundle.js';
|
|
5
6
|
import { mergeAgentNamespace } from '../../application/merge-agent-namespace.js';
|
|
7
|
+
import { getKnownVaults, loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
6
8
|
import { jsonResult, optionalPositiveInteger, resolveExecutionContext, vaultInput } from './shared.js';
|
|
7
9
|
export const vaultStatusInputSchema = { ...vaultInput };
|
|
8
10
|
export const vaultInitInputSchema = {
|
|
@@ -38,6 +40,19 @@ export const vaultImportInputSchema = {
|
|
|
38
40
|
snapshotFile: z.string().min(1).describe('Path to a .blvault snapshot to import. Divergent local files are preserved as *.conflict-<ts>.md.'),
|
|
39
41
|
skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
|
|
40
42
|
};
|
|
43
|
+
export const vaultExportBundleInputSchema = {
|
|
44
|
+
...vaultInput,
|
|
45
|
+
outputFile: z.string().min(1).describe('Path to write the portable .blbundle to (contains every known vault).')
|
|
46
|
+
};
|
|
47
|
+
export const vaultBundleListInputSchema = {
|
|
48
|
+
bundleFile: z.string().min(1).describe('Path to a .blbundle to inspect.')
|
|
49
|
+
};
|
|
50
|
+
export const vaultImportBundleInputSchema = {
|
|
51
|
+
bundleFile: z.string().min(1).describe('Path to a .blbundle to import. Divergent local files are preserved as *.conflict-<ts>.md.'),
|
|
52
|
+
only: z.string().min(1).optional().describe('Restore only this vault from the bundle (by name or recorded path). Omit to restore every vault.'),
|
|
53
|
+
targetVault: z.string().min(1).optional().describe('Target directory for the restored vault (only valid with "only"). Defaults to the recorded path.'),
|
|
54
|
+
skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
|
|
55
|
+
};
|
|
41
56
|
export const vaultProvisionInputSchema = {
|
|
42
57
|
...vaultInput,
|
|
43
58
|
name: z.string().min(1).optional().describe('Repository name to create. Defaults to brainlink-vault.'),
|
|
@@ -163,6 +178,38 @@ export const vaultImportTool = async (input) => {
|
|
|
163
178
|
return failure(context.vault, error);
|
|
164
179
|
}
|
|
165
180
|
};
|
|
181
|
+
export const vaultExportBundleTool = async (input) => {
|
|
182
|
+
const context = await resolveExecutionContext(input);
|
|
183
|
+
try {
|
|
184
|
+
const config = await loadBrainlinkConfig();
|
|
185
|
+
const result = await exportVaultsBundle(getKnownVaults(config), input.outputFile);
|
|
186
|
+
return jsonResult({ ok: true, ...result });
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return failure(context.vault, error);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
export const vaultBundleListTool = async (input) => {
|
|
193
|
+
try {
|
|
194
|
+
return jsonResult({ ok: true, bundleFile: input.bundleFile, vaults: await listBundleVaults(input.bundleFile) });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return jsonResult({ ok: false, bundleFile: input.bundleFile, error: error instanceof Error ? error.message : String(error) });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
export const vaultImportBundleTool = async (input) => {
|
|
201
|
+
try {
|
|
202
|
+
const result = await importVaultsBundle(input.bundleFile, {
|
|
203
|
+
only: input.only,
|
|
204
|
+
targetVault: input.targetVault,
|
|
205
|
+
skipIndex: input.skipIndex === true
|
|
206
|
+
});
|
|
207
|
+
return jsonResult({ ok: true, ...result });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return jsonResult({ ok: false, bundleFile: input.bundleFile, error: error instanceof Error ? error.message : String(error) });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
166
213
|
export const vaultMergeAgentsTool = async (input) => {
|
|
167
214
|
const context = await resolveExecutionContext(input);
|
|
168
215
|
try {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
|
|
2
2
|
export { addFileInputSchema, addFileTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
|
|
3
3
|
export { bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, indexInputSchema, indexTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, repairLinksInputSchema, repairLinksTool, sessionCloseInputSchema, sessionCloseTool, syncInputSchema, syncTool } from './tools/maintenance-tools.js';
|
|
4
|
-
export { vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
|
|
4
|
+
export { vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
|
package/package.json
CHANGED