codebase-onboarder 0.3.0 → 0.3.1
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 +12 -7
- package/cli/commands.js +1 -1
- package/package.json +1 -1
- package/public/index.html +2 -1
- package/public/js/api.js +21 -56
- package/public/js/serverSettings.js +18 -9
- package/public/login.html +34 -0
- package/server/apiAuth.js +32 -0
- package/server/apiSettings.js +6 -2
- package/server/auth.js +72 -0
- package/server/config.js +17 -13
- package/server/http.js +2 -2
- package/server/httpGuards.js +1 -0
- package/server/index.js +3 -3
- package/server/router.js +47 -18
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ with it instead of orphaning a process holding the scan cache.
|
|
|
202
202
|
Onboarder has two modes, one config file, and three ways to edit it — the CLI wizard, CLI flags, and the web UI's Server drawer all write the same validated `config.json` (`~/.config/onboarder/config.json`, mode `0600`).
|
|
203
203
|
|
|
204
204
|
- **Local (default)** — binds to loopback only, asks for no credentials. The safe default.
|
|
205
|
-
- **Self-hosted** — a fresh setup binds `0.0.0.0` for direct LAN/VPS access
|
|
205
|
+
- **Self-hosted** — a fresh setup binds `0.0.0.0` for direct LAN/VPS access. Any IP-literal address is accepted, while arbitrary Host names are not. Remote browsers get a themed access-key login page and exchange the key for a 7-day signed `HttpOnly`, `SameSite=Strict` session cookie; true localhost requests skip login. API clients can continue using `Authorization: Bearer <access-key>`. Rotate the key with `onboarder config key rotate`; the old key and every old browser session die on the next request.
|
|
206
206
|
|
|
207
207
|
```bash
|
|
208
208
|
onboarder setup # interactive wizard
|
|
@@ -223,7 +223,9 @@ onboarder https status # domain, URL, Caddyfile, and Caddy sta
|
|
|
223
223
|
onboarder doctor # config, access key, ports, DNS, TLS, and tunnels
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
-
A fresh self-hosted setup uses `0.0.0.0`, so a VPS is reachable at `http://<server-ip>:<port>` without a reverse proxy. A domain is optional for direct-IP access. If a domain is entered, setup asks whether to enable automatic HTTPS.
|
|
226
|
+
A fresh self-hosted setup uses `0.0.0.0`, so a VPS is reachable at `http://<server-ip>:<port>` without a reverse proxy. The server accepts IPv4 and IPv6 IP literals, including a public address that reaches the host through provider NAT, but still rejects arbitrary DNS Host headers. A domain is optional for direct-IP access. If a domain is entered, setup asks whether to enable automatic HTTPS.
|
|
227
|
+
|
|
228
|
+
When a remote browser opens the URL, Onboarder shows its themed sign-in page. The access key is sent in a POST body—not in the URL—and the browser stores only the signed session cookie. Opening the same server through `http://localhost:<port>` on that machine skips the page. Caddy and tunnel connections remain authenticated because their public Host is not loopback.
|
|
227
229
|
|
|
228
230
|
For trusted HTTPS, DNS must already point the domain to the VPS and inbound TCP `80` and `443` must be allowed in both the cloud security group/NSG and the host firewall. On Ubuntu:
|
|
229
231
|
|
|
@@ -236,7 +238,7 @@ onboarder https check
|
|
|
236
238
|
onboarder https setup
|
|
237
239
|
```
|
|
238
240
|
|
|
239
|
-
Onboarder writes a private `Caddyfile` beside `config.json`, validates it, and asks Caddy to obtain and renew the certificate. It never runs `sudo` or installs packages silently. Caddy proxies `https://map.example.com` to `http://127.0.0.1:4310`; Onboarder
|
|
241
|
+
Onboarder writes a private `Caddyfile` beside `config.json`, validates it, and asks Caddy to obtain and renew the certificate. It never runs `sudo` or installs packages silently. Caddy proxies `https://map.example.com` to `http://127.0.0.1:4310`; Onboarder recognizes the connection as remote and shows the access-key login page. A bare public IP cannot use a normal trusted domain certificate.
|
|
240
242
|
|
|
241
243
|
If startup reports `EADDRINUSE`, run `onboarder status` first. If it identifies an Onboarder PID, use `onboarder stop` or `onboarder restart`; otherwise inspect the unrelated listener with `ss -ltnp` or `lsof -i :4310`, or choose another port. The error names these recovery commands instead of printing only the raw Node error.
|
|
242
244
|
|
|
@@ -254,7 +256,9 @@ codebase-onboarder/
|
|
|
254
256
|
│ ├── index.js # createServer / startServer / startup banner
|
|
255
257
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
256
258
|
│ ├── pidfile.js # PID ownership for status / stop / restart
|
|
257
|
-
│ ├── router.js # Route table, live per-request settings,
|
|
259
|
+
│ ├── router.js # Route table, live per-request settings, auth & CSRF gates
|
|
260
|
+
│ ├── auth.js # Signed HttpOnly browser sessions
|
|
261
|
+
│ ├── apiAuth.js # Login/status/logout endpoints
|
|
258
262
|
│ ├── apiSettings.js# GET/PUT /api/settings, key rotation
|
|
259
263
|
│ ├── tunnel.js # Cloudflare & Tailscale status/commands
|
|
260
264
|
│ ├── https.js # Caddy config, ACME/TLS readiness & lifecycle
|
|
@@ -269,8 +273,9 @@ codebase-onboarder/
|
|
|
269
273
|
├── public/ # Frontend client application
|
|
270
274
|
│ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
|
|
271
275
|
│ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
272
|
-
│
|
|
273
|
-
└──
|
|
276
|
+
│ ├── index.html # Main application interface
|
|
277
|
+
│ └── login.html # Self-hosted access-key sign-in
|
|
278
|
+
└── tests/ # Comprehensive node:test suite (565 tests)
|
|
274
279
|
```
|
|
275
280
|
|
|
276
281
|
---
|
|
@@ -280,7 +285,7 @@ codebase-onboarder/
|
|
|
280
285
|
Onboarder includes a comprehensive automated test suite built with Node's native test runner:
|
|
281
286
|
|
|
282
287
|
```bash
|
|
283
|
-
# Run all
|
|
288
|
+
# Run all 565 tests
|
|
284
289
|
npm test
|
|
285
290
|
```
|
|
286
291
|
|
package/cli/commands.js
CHANGED
|
@@ -628,7 +628,7 @@ export async function runTunnel(kind, { flags = {}, out = console.log, err = con
|
|
|
628
628
|
if (m && !announced) {
|
|
629
629
|
announced = true;
|
|
630
630
|
out(tick + 'Public URL: ' + bold(m[0]));
|
|
631
|
-
out(dim(' Anyone with the URL still needs the access key: ' + m[0]
|
|
631
|
+
out(dim(' Anyone with the URL still needs the access key: ' + m[0]));
|
|
632
632
|
}
|
|
633
633
|
if (flags.verbose) process.stderr.write(text);
|
|
634
634
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebase-onboarder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Drop a path. Get a map. A zero-dependency codebase visualizer with a CLI onboarding wizard, a web UI, and an optional key-gated self-hosted mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/public/index.html
CHANGED
|
@@ -411,13 +411,14 @@
|
|
|
411
411
|
<button class="btn btn-ghost btn-sm" id="srvRotate" title="Mint a new key; the old one dies immediately">Rotate</button>
|
|
412
412
|
</div>
|
|
413
413
|
<div class="server-fresh-key" id="srvFreshKeyRow" hidden>
|
|
414
|
-
<p class="drawer-fine">New key — shown once.
|
|
414
|
+
<p class="drawer-fine">New key — shown once. This browser has been signed in with it:</p>
|
|
415
415
|
<div class="mcp-config-row">
|
|
416
416
|
<input type="text" class="text-input mcp-config-input" id="srvFreshKey" readonly spellcheck="false">
|
|
417
417
|
<button class="btn btn-ghost btn-sm" id="srvCopyKey">Copy</button>
|
|
418
418
|
</div>
|
|
419
419
|
</div>
|
|
420
420
|
<p class="drawer-fine" id="srvKeyHint"></p>
|
|
421
|
+
<button class="btn btn-ghost btn-sm" id="srvLogout">Sign out this browser</button>
|
|
421
422
|
</div>
|
|
422
423
|
|
|
423
424
|
<div class="server-section">
|
package/public/js/api.js
CHANGED
|
@@ -1,58 +1,19 @@
|
|
|
1
1
|
// Server round-trips. Thin and honest: JSON in, JSON (or an SSE stream) out,
|
|
2
2
|
// server error messages surfaced untouched.
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the URL (adopted once, then stripped so it does not linger in history), or
|
|
9
|
-
// the person pastes it into the Server drawer. Either way it lives in
|
|
10
|
-
// localStorage and rides along as an Authorization header from then on. Local
|
|
11
|
-
// mode ignores the header entirely, so sending it is harmless.
|
|
4
|
+
// Browser access is an HttpOnly session cookie set by the themed self-hosted
|
|
5
|
+
// login page. Same-origin fetches include it automatically, and JavaScript never
|
|
6
|
+
// reads or stores the access key. API clients can still use `Authorization:
|
|
7
|
+
// Bearer …`; local mode needs no credential at all.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// Lazy on purpose, and a function declaration for the same reason: running
|
|
18
|
-
// this at import would touch `window`, and the front-end test suite requires
|
|
19
|
-
// every module to be importable from Node. The first API call is still early
|
|
20
|
-
// enough — the key is adopted before any request leaves.
|
|
21
|
-
function adoptKeyFromUrl() {
|
|
22
|
-
if (urlKeyChecked) return;
|
|
23
|
-
urlKeyChecked = true;
|
|
24
|
-
try {
|
|
25
|
-
const url = new URL(window.location.href);
|
|
26
|
-
const key = url.searchParams.get('key');
|
|
27
|
-
if (!key) return;
|
|
28
|
-
localStorage.setItem(ACCESS_KEY_STORAGE, key);
|
|
29
|
-
url.searchParams.delete('key');
|
|
30
|
-
window.history.replaceState(null, '', url);
|
|
31
|
-
} catch {
|
|
32
|
-
/* no usable URL API — the drawer can still take the key by hand */
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function getAccessKey() {
|
|
37
|
-
adoptKeyFromUrl();
|
|
38
|
-
return localStorage.getItem(ACCESS_KEY_STORAGE) || '';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function setAccessKey(key) {
|
|
42
|
-
const trimmed = String(key || '').trim();
|
|
43
|
-
if (trimmed) localStorage.setItem(ACCESS_KEY_STORAGE, trimmed);
|
|
44
|
-
else localStorage.removeItem(ACCESS_KEY_STORAGE);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function authHeaders() {
|
|
48
|
-
const key = getAccessKey();
|
|
49
|
-
return key ? { authorization: `Bearer ${key}` } : {};
|
|
50
|
-
}
|
|
9
|
+
// 0.3.0 briefly stored a raw self-hosted key here. Remove that legacy value on
|
|
10
|
+
// first load so upgrading also removes the old secret rather than merely ignoring it.
|
|
11
|
+
try { localStorage.removeItem('onboarder.accessKey'); } catch { /* storage may be unavailable */ }
|
|
51
12
|
|
|
52
13
|
async function postJSON(url, body) {
|
|
53
14
|
const res = await fetch(url, {
|
|
54
15
|
method: 'POST',
|
|
55
|
-
headers: { 'content-type': 'application/json'
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
56
17
|
body: JSON.stringify(body),
|
|
57
18
|
});
|
|
58
19
|
let data = null;
|
|
@@ -73,14 +34,14 @@ export function scanOnServer(payload) {
|
|
|
73
34
|
|
|
74
35
|
export async function cleanupClone(cloneId) {
|
|
75
36
|
try {
|
|
76
|
-
await fetch('/api/scan/' + encodeURIComponent(cloneId), { method: 'DELETE'
|
|
37
|
+
await fetch('/api/scan/' + encodeURIComponent(cloneId), { method: 'DELETE' });
|
|
77
38
|
} catch {
|
|
78
39
|
/* best-effort: the temp dir expires on its own eventually */
|
|
79
40
|
}
|
|
80
41
|
}
|
|
81
42
|
|
|
82
43
|
export async function fetchFileText(scanId, path) {
|
|
83
|
-
const res = await fetch('/api/file?scan=' + encodeURIComponent(scanId) + '&path=' + encodeURIComponent(path)
|
|
44
|
+
const res = await fetch('/api/file?scan=' + encodeURIComponent(scanId) + '&path=' + encodeURIComponent(path));
|
|
84
45
|
if (!res.ok) throw new Error('Could not read that file from the server.');
|
|
85
46
|
return res.text();
|
|
86
47
|
}
|
|
@@ -90,7 +51,7 @@ export async function fetchFileText(scanId, path) {
|
|
|
90
51
|
// tool at, and this returns that honestly instead of pretending.
|
|
91
52
|
export async function fetchToolsStatus() {
|
|
92
53
|
try {
|
|
93
|
-
const res = await fetch('/api/tools'
|
|
54
|
+
const res = await fetch('/api/tools');
|
|
94
55
|
if (!res.ok) return null;
|
|
95
56
|
const data = await res.json();
|
|
96
57
|
return data && data.tools ? data.tools : null;
|
|
@@ -122,7 +83,7 @@ export async function streamToolInstall(tool, onEvent) {
|
|
|
122
83
|
const emit = typeof onEvent === 'function' ? onEvent : () => {};
|
|
123
84
|
const res = await fetch('/api/tools/install', {
|
|
124
85
|
method: 'POST',
|
|
125
|
-
headers: { 'content-type': 'application/json'
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
126
87
|
body: JSON.stringify({ tool }),
|
|
127
88
|
});
|
|
128
89
|
if (!res.ok || !res.body) {
|
|
@@ -160,7 +121,7 @@ export async function streamToolInstall(tool, onEvent) {
|
|
|
160
121
|
// this machine, which is exactly the side effect `postJSON` is here for.
|
|
161
122
|
export async function fetchMcpStatus() {
|
|
162
123
|
try {
|
|
163
|
-
const res = await fetch('/api/mcp'
|
|
124
|
+
const res = await fetch('/api/mcp');
|
|
164
125
|
if (!res.ok) return null;
|
|
165
126
|
return await res.json();
|
|
166
127
|
} catch {
|
|
@@ -181,7 +142,7 @@ export function stopMcpServer() {
|
|
|
181
142
|
// than on every poll.
|
|
182
143
|
export async function fetchMcpCommand() {
|
|
183
144
|
try {
|
|
184
|
-
const res = await fetch('/api/mcp/command'
|
|
145
|
+
const res = await fetch('/api/mcp/command');
|
|
185
146
|
if (!res.ok) return null;
|
|
186
147
|
return await res.json();
|
|
187
148
|
} catch {
|
|
@@ -195,7 +156,7 @@ export async function fetchMcpCommand() {
|
|
|
195
156
|
export async function* streamExplain({ baseUrl, apiKey, model, messages, maxTokens = 1200, providerOptions = {} }) {
|
|
196
157
|
const res = await fetch('/api/explain', {
|
|
197
158
|
method: 'POST',
|
|
198
|
-
headers: { 'content-type': 'application/json'
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
199
160
|
body: JSON.stringify({ baseUrl, apiKey, model, messages, stream: true, max_tokens: maxTokens, ...providerOptions }),
|
|
200
161
|
});
|
|
201
162
|
|
|
@@ -241,7 +202,7 @@ export async function* streamExplain({ baseUrl, apiKey, model, messages, maxToke
|
|
|
241
202
|
// rotation is a POST because it mints a new key on the server — the one and
|
|
242
203
|
// only time a key ever crosses the wire in the clear.
|
|
243
204
|
export async function fetchServerSettings() {
|
|
244
|
-
const res = await fetch('/api/settings'
|
|
205
|
+
const res = await fetch('/api/settings');
|
|
245
206
|
const data = await res.json().catch(() => null);
|
|
246
207
|
if (!res.ok) throw new Error(data?.error || `The server said ${res.status}.`);
|
|
247
208
|
return data;
|
|
@@ -250,7 +211,7 @@ export async function fetchServerSettings() {
|
|
|
250
211
|
export async function updateServerSettings(patch) {
|
|
251
212
|
const res = await fetch('/api/settings', {
|
|
252
213
|
method: 'PUT',
|
|
253
|
-
headers: { 'content-type': 'application/json'
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
254
215
|
body: JSON.stringify(patch),
|
|
255
216
|
});
|
|
256
217
|
const data = await res.json().catch(() => null);
|
|
@@ -261,3 +222,7 @@ export async function updateServerSettings(patch) {
|
|
|
261
222
|
export function rotateServerAccessKey() {
|
|
262
223
|
return postJSON('/api/settings/access-key', {});
|
|
263
224
|
}
|
|
225
|
+
|
|
226
|
+
export function logoutRemoteSession() {
|
|
227
|
+
return postJSON('/api/auth/logout', {});
|
|
228
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// The Server drawer: the web face of the same config file the CLI wizard
|
|
2
2
|
// writes. Reads come back with the key masked; saves send only the changed
|
|
3
|
-
// keys; rotation reveals the new key once and
|
|
3
|
+
// keys; rotation reveals the new key once and signs this browser in.
|
|
4
4
|
//
|
|
5
5
|
// The module owns its own DOM and its own round-trips. What it borrows from
|
|
6
6
|
// app.js is the drawer choreography — one scrim at a time, Escape to close —
|
|
7
7
|
// which is why it returns open/close/isOpen instead of wiring the topbar
|
|
8
8
|
// button itself.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { fetchServerSettings, updateServerSettings, rotateServerAccessKey, logoutRemoteSession } from '/js/api.js';
|
|
11
11
|
|
|
12
12
|
// A function declaration, not a const arrow: the import-time DOM check in
|
|
13
13
|
// tests/frontend.test.js blanks top-level function bodies but reads const
|
|
@@ -29,7 +29,7 @@ export function createServerDrawer(options = {}) {
|
|
|
29
29
|
const dom = {};
|
|
30
30
|
[
|
|
31
31
|
'serverDrawer', 'serverScrim', 'serverClose', 'serverModeNote', 'srvMode', 'srvHost', 'srvPort',
|
|
32
|
-
'srvDomain', 'srvDomainField', 'srvHttps', 'srvHttpsField', 'srvHttpsStatus', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate',
|
|
32
|
+
'srvDomain', 'srvDomainField', 'srvHttps', 'srvHttpsField', 'srvHttpsStatus', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate', 'srvLogout',
|
|
33
33
|
'srvFreshKeyRow', 'srvFreshKey', 'srvCopyKey', 'srvKeyHint', 'srvName', 'srvEmail',
|
|
34
34
|
'srvTunnelCloudflare', 'srvTunnelCloudflareStatus', 'srvTunnelTailscale', 'srvTunnelTailscaleStatus',
|
|
35
35
|
'srvSave', 'srvStatus', 'srvConfigPath',
|
|
@@ -74,9 +74,9 @@ export function createServerDrawer(options = {}) {
|
|
|
74
74
|
tunnelLine(dom.srvTunnelTailscaleStatus, data.tunnels?.tailscale);
|
|
75
75
|
dom.srvConfigPath.textContent = `Config file: ${data.configFile}`;
|
|
76
76
|
dom.srvKeyMasked.value = s.accessKeyMasked || (s.hasAccessKey ? '' : '(none set — the API refuses every call)');
|
|
77
|
-
dom.srvKeyHint.textContent =
|
|
78
|
-
? '
|
|
79
|
-
: '
|
|
77
|
+
dom.srvKeyHint.textContent = s.hasAccessKey
|
|
78
|
+
? 'Remote browsers sign in once; the key is exchanged for a secure HTTP-only session.'
|
|
79
|
+
: 'No key is set. Generate one before remote access is possible.';
|
|
80
80
|
applyModeVisibility();
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -145,9 +145,8 @@ export function createServerDrawer(options = {}) {
|
|
|
145
145
|
status('Rotating…');
|
|
146
146
|
try {
|
|
147
147
|
const data = await rotateServerAccessKey();
|
|
148
|
-
// The
|
|
149
|
-
//
|
|
150
|
-
setAccessKey(data.accessKey);
|
|
148
|
+
// The server refreshes this browser's signed session as part of rotation.
|
|
149
|
+
// The raw key is shown once for copying elsewhere, never stored locally.
|
|
151
150
|
render({ ...current, settings: data.settings });
|
|
152
151
|
dom.srvFreshKey.value = data.accessKey;
|
|
153
152
|
dom.srvFreshKeyRow.hidden = false; // render() does not know about the reveal
|
|
@@ -157,10 +156,20 @@ export function createServerDrawer(options = {}) {
|
|
|
157
156
|
}
|
|
158
157
|
}
|
|
159
158
|
|
|
159
|
+
async function signOut() {
|
|
160
|
+
try {
|
|
161
|
+
await logoutRemoteSession();
|
|
162
|
+
location.replace('/');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
status(err.message, 'err');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
160
168
|
dom.srvMode.addEventListener('change', applyModeVisibility);
|
|
161
169
|
dom.srvDomain.addEventListener('input', applyModeVisibility);
|
|
162
170
|
dom.srvSave.addEventListener('click', save);
|
|
163
171
|
dom.srvRotate.addEventListener('click', rotate);
|
|
172
|
+
dom.srvLogout.addEventListener('click', signOut);
|
|
164
173
|
dom.serverClose.addEventListener('click', close);
|
|
165
174
|
dom.serverScrim.addEventListener('click', close);
|
|
166
175
|
dom.srvCopyKey.addEventListener('click', async () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
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">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7
|
+
<title>Sign in · Onboarder</title>
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23f7f4ed'/%3E%3Ccircle cx='9' cy='16' r='4' fill='none' stroke='%232a2721' stroke-width='2'/%3E%3Ccircle cx='24' cy='8' r='3.2' fill='none' stroke='%232e5d43' stroke-width='2'/%3E%3Ccircle cx='24' cy='24' r='3.2' fill='none' stroke='%232a2721' stroke-width='2'/%3E%3Cpath d='M13 14 L21 9 M13 18 L21 23' stroke='%232a2721' stroke-width='1.6'/%3E%3C/svg%3E">
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:light dark;--paper:#f7f4ed;--paper2:#efe9dc;--card:#fffdf7;--ink:#2a2721;--ink2:#57503f;--faint:#8a8577;--line:rgba(42,39,33,.22);--accent:#2e5d43;--soft:#dce8d8;--warn:#a4442a;--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;--shadow:3px 3px 0 rgba(42,39,33,.9)}
|
|
11
|
+
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#211e19;--paper2:#292520;--card:#2b2721;--ink:#ece7db;--ink2:#c9c2b2;--faint:#9b9484;--line:rgba(236,231,219,.22);--accent:#8fbd9f;--soft:#243d2c;--warn:#d97b5a;--shadow:3px 3px 0 rgba(0,0,0,.6)}}
|
|
12
|
+
*{box-sizing:border-box}.username-field{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}html,body{min-height:100%}body{margin:0;display:grid;place-items:center;padding:28px;overflow-x:hidden;background-color:var(--paper);background-image:radial-gradient(var(--line) .7px,transparent .7px);background-size:18px 18px;color:var(--ink);font-family:var(--sans)}.shell{width:min(100%,430px);position:relative}.shell:before{content:"";position:fixed;inset:14px;border:1px solid var(--line);pointer-events:none}.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px}.mark{width:42px;height:42px;display:grid;place-items:center;background:var(--card);border:1.5px solid var(--ink);box-shadow:var(--shadow)}.mark svg{width:28px}.wordmark{font:600 25px/1 var(--serif)}.tagline{margin:5px 0 0;color:var(--faint);font-size:12px}.card{padding:clamp(25px,6vw,38px);background:var(--card);border:1.5px solid var(--ink);border-radius:10px;box-shadow:var(--shadow)}.eyebrow{display:flex;align-items:center;gap:7px;margin:0 0 13px;color:var(--accent);font:600 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase}.eyebrow:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}h1{margin:0;font:600 clamp(30px,8vw,40px)/1.03 var(--serif);letter-spacing:-.025em}.intro{margin:14px 0 24px;color:var(--ink2);font-size:14px;line-height:1.6}label{display:block;font:600 12px var(--mono)}.input-wrap{position:relative;margin-top:8px}input{width:100%;height:48px;padding:0 47px 0 13px;border:1.5px solid var(--ink);border-radius:5px;outline:0;background:var(--paper);color:var(--ink);font:14px var(--mono)}input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--soft)}.toggle{position:absolute;top:5px;right:5px;width:38px;height:38px;border:0;border-radius:4px;background:transparent;color:var(--ink2);cursor:pointer}.toggle:hover,.toggle:focus-visible{background:var(--paper2);color:var(--ink);outline:0}.submit{width:100%;height:46px;margin-top:13px;border:1.5px solid var(--ink);border-radius:5px;background:var(--ink);color:var(--paper);font:600 12px var(--mono);cursor:pointer;transition:.08s}.submit:hover{transform:translate(-1px,-1px);box-shadow:2px 2px 0 var(--accent)}.submit:disabled{cursor:wait;opacity:.65}.message{min-height:21px;margin:13px 0 0;color:var(--warn);font-size:12.5px}.message[data-ok=true]{color:var(--accent)}.privacy{display:flex;gap:9px;margin:23px -4px 0;padding-top:17px;border-top:1px solid var(--line);color:var(--faint);font-size:11.5px;line-height:1.5}.privacy svg{flex:none;width:15px;margin-top:1px}.foot{margin:18px 0 0;text-align:center;color:var(--faint);font:10.5px var(--mono)}@media(max-width:480px){body{padding:20px;place-items:start center}.shell{margin:5vh 0}.shell:before{inset:8px}.card{padding:24px 20px}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main class="shell">
|
|
17
|
+
<div class="brand"><div class="mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="9" cy="16" r="4"/><circle cx="24" cy="8" r="3.2" stroke="#2e5d43"/><circle cx="24" cy="24" r="3.2"/><path d="M13 14 21 9M13 18 21 23"/></svg></div><div><div class="wordmark">onboarder</div><p class="tagline">a map for any codebase</p></div></div>
|
|
18
|
+
<section class="card" aria-labelledby="loginTitle">
|
|
19
|
+
<p class="eyebrow">Private server</p><h1 id="loginTitle">Welcome back.</h1>
|
|
20
|
+
<p class="intro">This Onboarder instance is self-hosted. Enter its access key to open the codebase map.</p>
|
|
21
|
+
<form id="loginForm"><label for="accessKey">Access key</label><input id="username" name="username" type="text" value="onboarder" autocomplete="username" tabindex="-1" aria-hidden="true" class="username-field"><div class="input-wrap">
|
|
22
|
+
<input id="accessKey" name="accessKey" type="password" placeholder="ob_••••••••••••" autocomplete="current-password" autocapitalize="off" spellcheck="false" required autofocus>
|
|
23
|
+
<button class="toggle" id="toggleKey" type="button" aria-label="Show access key" aria-pressed="false"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.5"/></svg></button>
|
|
24
|
+
</div><button class="submit" type="submit">Unlock Onboarder →</button><p class="message" id="message" role="status" aria-live="polite"></p></form>
|
|
25
|
+
<div class="privacy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span>Your key is exchanged for a secure, HTTP-only session. It is not stored in this page or added to the address bar.</span></div>
|
|
26
|
+
</section><p class="foot">Everything runs on your server.</p>
|
|
27
|
+
</main>
|
|
28
|
+
<script type="module">
|
|
29
|
+
const form=document.getElementById('loginForm'),input=document.getElementById('accessKey'),message=document.getElementById('message'),toggle=document.getElementById('toggleKey'),submit=form.querySelector('.submit');
|
|
30
|
+
toggle.addEventListener('click',()=>{const showing=input.type==='text';input.type=showing?'password':'text';toggle.setAttribute('aria-pressed',String(!showing));toggle.setAttribute('aria-label',showing?'Show access key':'Hide access key');input.focus()});
|
|
31
|
+
form.addEventListener('submit',async event=>{event.preventDefault();const accessKey=input.value.trim();if(!accessKey)return;submit.disabled=true;message.textContent='Checking…';message.dataset.ok='false';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessKey})}),data=await res.json().catch(()=>({}));if(!res.ok)throw new Error(data.error||'That access key is not correct.');message.textContent='Key accepted. Opening Onboarder…';message.dataset.ok='true';window.location.replace('/')}catch(error){message.textContent=error.message;message.dataset.ok='false';input.select()}finally{submit.disabled=false}});
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The access-key form. Kept tiny and independent from the main app: an
|
|
2
|
+
// unauthenticated visitor must be able to load this page without loading any
|
|
3
|
+
// analyzer, settings drawer, or other application surface.
|
|
4
|
+
|
|
5
|
+
import { accessKeysMatch, authReason } from './config.js';
|
|
6
|
+
import { hasValidSession, sessionCookie } from './auth.js';
|
|
7
|
+
import { sendError, sendJSON } from './http.js';
|
|
8
|
+
|
|
9
|
+
export function handleAuthStatus(req, res, settings) {
|
|
10
|
+
sendJSON(res, 200, {
|
|
11
|
+
authenticated: !authReason(req, settings) || hasValidSession(req, settings),
|
|
12
|
+
configured: Boolean(settings.accessKey),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function handleLogin(req, res, body, settings) {
|
|
17
|
+
if (!settings.accessKey) return sendError(res, 503, 'This server has no access key configured. Run `onboarder setup` on the server.');
|
|
18
|
+
if (!accessKeysMatch(settings.accessKey, body?.accessKey)) {
|
|
19
|
+
return sendError(res, 401, 'That access key is not correct.');
|
|
20
|
+
}
|
|
21
|
+
sendJSON(res, 200, { ok: true }, {
|
|
22
|
+
'cache-control': 'no-store',
|
|
23
|
+
'set-cookie': sessionCookie(req, settings),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function handleLogout(req, res) {
|
|
28
|
+
sendJSON(res, 200, { ok: true }, {
|
|
29
|
+
'cache-control': 'no-store',
|
|
30
|
+
'set-cookie': sessionCookie(req, {}, { clear: true }),
|
|
31
|
+
});
|
|
32
|
+
}
|
package/server/apiSettings.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readSettings, updateSettings, writeSettings, configPath,
|
|
13
13
|
} from './config.js';
|
|
14
14
|
import { sendError, sendJSON } from './http.js';
|
|
15
|
+
import { sessionCookie } from './auth.js';
|
|
15
16
|
import { tunnelStatus } from './tunnel.js';
|
|
16
17
|
import { httpsStatus } from './https.js';
|
|
17
18
|
|
|
@@ -96,7 +97,7 @@ export async function handleUpdateSettings(res, body, config) {
|
|
|
96
97
|
// Rotation returns the new key in the clear, once. It is never in a GET after
|
|
97
98
|
// that — `hasAccessKey`/`accessKeyMasked` are all the readback there is. The
|
|
98
99
|
// person copies it from this response into the devices that need it.
|
|
99
|
-
export async function handleRotateAccessKey(res, config) {
|
|
100
|
+
export async function handleRotateAccessKey(req, res, config) {
|
|
100
101
|
const file = settingsFile(config);
|
|
101
102
|
if (!file) {
|
|
102
103
|
return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
|
|
@@ -108,7 +109,10 @@ export async function handleRotateAccessKey(res, config) {
|
|
|
108
109
|
sendJSON(res, 200, {
|
|
109
110
|
accessKey,
|
|
110
111
|
settings: publicSettings(next),
|
|
111
|
-
note: 'Shown once.
|
|
112
|
+
note: 'Shown once. This browser is signed in with the new key; update every other device.',
|
|
113
|
+
}, {
|
|
114
|
+
'cache-control': 'no-store',
|
|
115
|
+
'set-cookie': sessionCookie(req, next),
|
|
112
116
|
});
|
|
113
117
|
} catch (err) {
|
|
114
118
|
sendError(res, 400, err.message || 'The key could not be rotated.');
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Browser sessions for the self-hosted access-key gate.
|
|
2
|
+
//
|
|
3
|
+
// The access key itself never becomes a cookie. A successful login mints an
|
|
4
|
+
// opaque timestamp + HMAC using the configured key as the signing secret. The
|
|
5
|
+
// browser gets an HttpOnly cookie, JavaScript cannot read it, and rotating the
|
|
6
|
+
// access key invalidates every old session immediately.
|
|
7
|
+
|
|
8
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const SESSION_COOKIE = 'onboarder_session';
|
|
11
|
+
export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
12
|
+
|
|
13
|
+
function signature(expires, accessKey) {
|
|
14
|
+
return createHmac('sha256', accessKey)
|
|
15
|
+
.update(`onboarder-session-v1:${expires}`)
|
|
16
|
+
.digest('base64url');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createSession(settings, now = Date.now()) {
|
|
20
|
+
const key = String(settings?.accessKey || '');
|
|
21
|
+
if (!key) return '';
|
|
22
|
+
const expires = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
|
|
23
|
+
return `${expires}.${signature(expires, key)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cookieValue(req, name) {
|
|
27
|
+
const header = String(req.headers?.cookie || '');
|
|
28
|
+
for (const part of header.split(';')) {
|
|
29
|
+
const at = part.indexOf('=');
|
|
30
|
+
if (at === -1) continue;
|
|
31
|
+
if (part.slice(0, at).trim() !== name) continue;
|
|
32
|
+
try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return ''; }
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasValidSession(req, settings, now = Date.now()) {
|
|
38
|
+
const key = String(settings?.accessKey || '');
|
|
39
|
+
const token = cookieValue(req, SESSION_COOKIE);
|
|
40
|
+
const dot = token.indexOf('.');
|
|
41
|
+
if (!key || dot < 1) return false;
|
|
42
|
+
|
|
43
|
+
const expires = Number(token.slice(0, dot));
|
|
44
|
+
const supplied = token.slice(dot + 1);
|
|
45
|
+
if (!Number.isSafeInteger(expires) || expires <= Math.floor(now / 1000) || !supplied) return false;
|
|
46
|
+
|
|
47
|
+
const expected = signature(expires, key);
|
|
48
|
+
const a = Buffer.from(supplied);
|
|
49
|
+
const b = Buffer.from(expected);
|
|
50
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requestIsSecure(req) {
|
|
54
|
+
if (req.socket?.encrypted) return true;
|
|
55
|
+
const forwarded = String(req.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
56
|
+
return forwarded === 'https';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sessionCookie(req, settings, { clear = false } = {}) {
|
|
60
|
+
const parts = [
|
|
61
|
+
`${SESSION_COOKIE}=${clear ? '' : encodeURIComponent(createSession(settings))}`,
|
|
62
|
+
'Path=/',
|
|
63
|
+
'HttpOnly',
|
|
64
|
+
'SameSite=Strict',
|
|
65
|
+
`Max-Age=${clear ? 0 : SESSION_TTL_SECONDS}`,
|
|
66
|
+
];
|
|
67
|
+
// A direct bare-IP deployment may still be plain HTTP, where Secure would make
|
|
68
|
+
// the cookie unusable. Caddy forwards the original scheme, so domain HTTPS
|
|
69
|
+
// gets the production-safe flag automatically.
|
|
70
|
+
if (requestIsSecure(req)) parts.push('Secure');
|
|
71
|
+
return parts.join('; ');
|
|
72
|
+
}
|
package/server/config.js
CHANGED
|
@@ -131,9 +131,7 @@ export function maskAccessKey(key) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export function browserUrl(settings) {
|
|
134
|
-
|
|
135
|
-
if (settings.mode === 'self-hosted' && settings.accessKey) url.searchParams.set('key', settings.accessKey);
|
|
136
|
-
return url.toString();
|
|
134
|
+
return new URL(serverUrls(settings).local).toString();
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
@@ -192,16 +190,11 @@ export function allowedHosts(value = DEFAULT_SETTINGS) {
|
|
|
192
190
|
// A wildcard bind is useful as a literal Host value to diagnostics, even
|
|
193
191
|
// though browsers normally address the machine by one of its real IPs.
|
|
194
192
|
hosts.add(settings.host.toLowerCase());
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
for (const ip of ownInterfaceHosts()) if (!ip.includes(':')) hosts.add(ip);
|
|
201
|
-
} else if (settings.host === '::') {
|
|
202
|
-
hosts.add('ipv6:*');
|
|
203
|
-
for (const ip of ownInterfaceHosts()) if (ip.includes(':')) hosts.add(ip);
|
|
204
|
-
}
|
|
193
|
+
// Any network bind can sit behind NAT: a VPS may be configured as a private
|
|
194
|
+
// interface address while browsers address its public/NAT IP. Accept every IP
|
|
195
|
+
// literal, never arbitrary DNS names; the access key protects remote data.
|
|
196
|
+
hosts.add('ip:*');
|
|
197
|
+
for (const ip of ownInterfaceHosts()) if (!isLoopbackHost(ip)) hosts.add(ip);
|
|
205
198
|
}
|
|
206
199
|
if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
|
|
207
200
|
if (settings.tunnel.tailscale) hosts.add('*.ts.net');
|
|
@@ -220,9 +213,20 @@ export function bearerToken(req) {
|
|
|
220
213
|
return match ? match[1].trim() : '';
|
|
221
214
|
}
|
|
222
215
|
|
|
216
|
+
export function requestIsLoopback(req) {
|
|
217
|
+
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
218
|
+
return remote === '127.0.0.1' || remote === '::1';
|
|
219
|
+
}
|
|
220
|
+
|
|
223
221
|
export function authReason(req, settings = DEFAULT_SETTINGS) {
|
|
224
222
|
const normalized = normalizeSettings(settings);
|
|
225
223
|
if (normalized.mode !== 'self-hosted') return null;
|
|
224
|
+
// A self-hosted server can still be opened on the same machine. Caddy and
|
|
225
|
+
// tunnels arrive from loopback but keep the public Host, so both signals must
|
|
226
|
+
// agree before a login page is skipped.
|
|
227
|
+
const host = String(req.headers?.host || '').toLowerCase();
|
|
228
|
+
const hostIsLoopback = isLoopbackHost(host.replace(/:\d+$/, '')) || /^localhost:\d+$/.test(host);
|
|
229
|
+
if (requestIsLoopback(req) && hostIsLoopback) return null;
|
|
226
230
|
if (!normalized.accessKey) return 'Self-hosted mode has no access key configured.';
|
|
227
231
|
const token = bearerToken(req);
|
|
228
232
|
if (!accessKeysMatch(normalized.accessKey, token)) return 'A valid access key is required.';
|
package/server/http.js
CHANGED
|
@@ -37,8 +37,8 @@ export function readBody(req, limit = MAX_BODY_BYTES) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function sendJSON(res, status, data) {
|
|
41
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
40
|
+
export function sendJSON(res, status, data, headers = {}) {
|
|
41
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers });
|
|
42
42
|
res.end(JSON.stringify(data));
|
|
43
43
|
}
|
|
44
44
|
|
package/server/httpGuards.js
CHANGED
|
@@ -43,6 +43,7 @@ export function rebindingReason(req, extraHosts = []) {
|
|
|
43
43
|
const name = hostnameOf(host);
|
|
44
44
|
if (LOOPBACK_HOSTS.has(name)) return null;
|
|
45
45
|
for (const extra of extraHosts) {
|
|
46
|
+
if (extra === 'ip:*' && net.isIP(name.replace(/^\[|\]$/g, '')) > 0) return null;
|
|
46
47
|
if (extra === 'ipv4:*' && net.isIP(name) === 4) return null;
|
|
47
48
|
if (extra === 'ipv6:*' && net.isIP(name.replace(/^\[|\]$/g, '')) === 6) return null;
|
|
48
49
|
if (extra.startsWith('*.')) {
|
package/server/index.js
CHANGED
|
@@ -82,7 +82,7 @@ export function startupBanner(settings, { configFile } = {}) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
if (settings.mode === 'self-hosted' && settings.accessKey) {
|
|
85
|
-
lines.push('
|
|
85
|
+
lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
|
|
86
86
|
}
|
|
87
87
|
if (settings.mode === 'self-hosted' && !settings.accessKey) {
|
|
88
88
|
lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
|
|
@@ -176,8 +176,8 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
176
176
|
log(startupBanner(live, { configFile }));
|
|
177
177
|
|
|
178
178
|
const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
|
|
179
|
-
//
|
|
180
|
-
//
|
|
179
|
+
// Local requests never need a credential. Remote browsers are sent to the
|
|
180
|
+
// themed login page and exchange the key for an HttpOnly session cookie.
|
|
181
181
|
if (shouldOpen) openInBrowser(browserUrl(live));
|
|
182
182
|
return { server, settings: live, host, port };
|
|
183
183
|
}
|
package/server/router.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
// The route table and the
|
|
1
|
+
// The route table and the gates in front of it.
|
|
2
2
|
//
|
|
3
3
|
// Everything arrives here: one function decides whether a request is allowed to
|
|
4
4
|
// be answered at all, then which handler answers it. The routes are a list rather
|
|
5
5
|
// than a ladder of `if` statements so that the whole surface of the server is
|
|
6
|
-
// visible in one screen
|
|
6
|
+
// visible in one screen, and everything else is a static file.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `evil.com` into our own origin, and `Origin`/`Sec-Fetch-Site` stops a page the
|
|
13
|
-
// person happened to have open from driving the API.
|
|
8
|
+
// There are three concerns. Local mode relies on `Host`, `Origin`, and
|
|
9
|
+
// `Sec-Fetch-Site` to stop another page and DNS rebinding from driving it.
|
|
10
|
+
// Self-hosted mode adds real access-key authentication: local browser requests
|
|
11
|
+
// stay open, remote browsers get a signed session, and API clients use Bearer.
|
|
14
12
|
|
|
15
13
|
import { handleDocs } from './apiDocs.js';
|
|
16
14
|
import { handleFile } from './apiFile.js';
|
|
@@ -25,7 +23,9 @@ import { handleDiff, handleDiffRefs } from './apiDiff.js';
|
|
|
25
23
|
import { handleToolsInstall, handleToolsRun, handleToolsStatus } from './apiTools.js';
|
|
26
24
|
import { handleMcpStart, handleMcpStatus, handleMcpStop, handleMcpCommand } from './apiMcp.js';
|
|
27
25
|
import { handleGetSettings, handleRotateAccessKey, handleUpdateSettings } from './apiSettings.js';
|
|
28
|
-
import {
|
|
26
|
+
import { handleAuthStatus, handleLogin, handleLogout } from './apiAuth.js';
|
|
27
|
+
import { accessKeysMatch, allowedHosts, authReason, bearerToken, DEFAULT_SETTINGS } from './config.js';
|
|
28
|
+
import { hasValidSession } from './auth.js';
|
|
29
29
|
|
|
30
30
|
const ROUTES = [
|
|
31
31
|
{
|
|
@@ -110,6 +110,18 @@ const ROUTES = [
|
|
|
110
110
|
method: 'GET', path: '/api/health',
|
|
111
111
|
run: ({ res }) => sendJSON(res, 200, { ok: true }),
|
|
112
112
|
},
|
|
113
|
+
{
|
|
114
|
+
method: 'GET', path: '/api/auth/status', sameOrigin: true,
|
|
115
|
+
run: ({ req, res, settings }) => handleAuthStatus(req, res, settings),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
method: 'POST', path: '/api/auth/login', body: true, sameOrigin: true,
|
|
119
|
+
run: ({ req, res, body, settings }) => handleLogin(req, res, body, settings),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: 'POST', path: '/api/auth/logout', body: true, sameOrigin: true,
|
|
123
|
+
run: ({ req, res }) => handleLogout(req, res),
|
|
124
|
+
},
|
|
113
125
|
{
|
|
114
126
|
// The settings drawer and the CLI read the same public shape: everything
|
|
115
127
|
// about the configuration except the access key itself.
|
|
@@ -128,7 +140,7 @@ const ROUTES = [
|
|
|
128
140
|
// this response, never readable again. In self-hosted mode this endpoint
|
|
129
141
|
// is itself behind the current key, so rotation requires possession.
|
|
130
142
|
method: 'POST', path: '/api/settings/access-key', body: true,
|
|
131
|
-
run: ({ res, config }) => handleRotateAccessKey(res, config),
|
|
143
|
+
run: ({ req, res, config }) => handleRotateAccessKey(req, res, config),
|
|
132
144
|
},
|
|
133
145
|
];
|
|
134
146
|
|
|
@@ -184,6 +196,7 @@ export function createRouter(config) {
|
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
const found = matchRoute(req.method, url.pathname);
|
|
199
|
+
const publicAuthRoute = ['/api/health', '/api/auth/status', '/api/auth/login', '/api/auth/logout'].includes(url.pathname);
|
|
187
200
|
if (req.method !== 'GET' || found?.route.sameOrigin) {
|
|
188
201
|
const foreign = crossOriginReason(req);
|
|
189
202
|
if (foreign) {
|
|
@@ -191,18 +204,34 @@ export function createRouter(config) {
|
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
// Bearer clients keep their existing API contract. A browser gets a signed,
|
|
208
|
+
// HttpOnly session from the login form instead of storing the raw key.
|
|
209
|
+
const bearerClient = accessKeysMatch(settings.accessKey, bearerToken(req));
|
|
210
|
+
const browserAuthenticated = hasValidSession(req, settings);
|
|
211
|
+
const authDenied = authReason(req, settings);
|
|
212
|
+
const locallyExempt = !authDenied;
|
|
213
|
+
const authenticated = bearerClient || browserAuthenticated || locallyExempt;
|
|
214
|
+
const remoteSelfHosted = settings.mode === 'self-hosted' && Boolean(authDenied);
|
|
215
|
+
|
|
216
|
+
if (remoteSelfHosted && !authenticated && !publicAuthRoute) {
|
|
217
|
+
// API callers keep a machine-readable 401. A browser navigation gets the
|
|
218
|
+
// themed sign-in document so the user never has to paste JSON into a tab.
|
|
219
|
+
const accepts = String(req.headers?.accept || '');
|
|
220
|
+
if (req.method === 'GET' && (url.pathname === '/' || accepts.includes('text/html'))) {
|
|
221
|
+
res.statusCode = 200;
|
|
222
|
+
return await serveStatic(res, '/login.html', config);
|
|
223
|
+
}
|
|
224
|
+
return sendError(res, 401, authDenied || 'Sign in with the Onboarder access key first.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === '/api/auth/logout') {
|
|
228
|
+
// Always clear the browser cookie, even if it had already expired.
|
|
229
|
+
return handleLogout(req, res);
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
if (found) {
|
|
204
233
|
const body = found.route.body ? await readBody(req) : null;
|
|
205
|
-
return await found.route.run({ req, res, url, body, rest: found.rest, config });
|
|
234
|
+
return await found.route.run({ req, res, url, body, rest: found.rest, config, settings });
|
|
206
235
|
}
|
|
207
236
|
|
|
208
237
|
if (req.method === 'GET') return await serveStatic(res, url.pathname, config);
|