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 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; every API call requires a Bearer access key. Re-running setup preserves an existing loopback tunnel layout. Rotate the key with `onboarder config key rotate`; the old key dies on the next request.
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 continues to enforce the access key on every API call. A bare public IP cannot use a normal trusted domain certificate.
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, Bearer gate
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
- │ └── index.html # Main application interface
273
- └── tests/ # Comprehensive node:test suite (560 unit tests)
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 560 tests
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] + '?key=<your-key>'));
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.0",
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. It is already saved in this browser:</p>
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
- // ---------------------------------------------------------------- access key --
5
- //
6
- // A self-hosted server gates every API route behind a Bearer key. The browser
7
- // learns it one of two ways: the startup banner links here with `?key=…` in
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
- const ACCESS_KEY_STORAGE = 'onboarder.accessKey';
14
-
15
- let urlKeyChecked = false;
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', ...authHeaders() },
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', headers: authHeaders() });
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), { headers: authHeaders() });
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', { headers: authHeaders() });
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', ...authHeaders() },
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', { headers: authHeaders() });
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', { headers: authHeaders() });
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', ...authHeaders() },
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', { headers: authHeaders() });
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', ...authHeaders() },
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 stores it in this browser.
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 { getAccessKey, setAccessKey, fetchServerSettings, updateServerSettings, rotateServerAccessKey } from '/js/api.js';
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 = getAccessKey()
78
- ? 'This browser holds a key and sends it with every request.'
79
- : 'This browser has no key saved. Paste one via rotate, or open the link from the startup banner.';
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 one moment a key is ever shown. It goes straight into this
149
- // browser's localStorage too, so this tab keeps working without a paste.
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
+ }
@@ -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. Update every device that connects remotely; the old key is dead.',
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
- const url = new URL(serverUrls(settings).local);
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
- if (settings.host === '0.0.0.0') {
196
- // A cloud VPS commonly reaches its public address through provider NAT,
197
- // so that address is not present in os.networkInterfaces(). These markers
198
- // let the guard accept an IP literal without accepting arbitrary domains.
199
- hosts.add('ipv4:*');
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
 
@@ -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(' Browser this URL is opened with the access key automatically; the key is removed from the address bar.');
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
- // The browser adopts a self-hosted key from the query string, stores it locally,
180
- // and removes the secret from the visible URL before any API request.
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 two gates in front of it.
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 — six endpoints, and everything else is a static file.
6
+ // visible in one screen, and everything else is a static file.
7
7
  //
8
- // Neither gate is authentication. A local server with no accounts cannot
9
- // authenticate anyone; what it can do is refuse requests that a browser on some
10
- // other site made on the person's behalf. `server/httpGuards.js` explains both
11
- // attacks; the short version is that `Host` stops DNS rebinding from turning
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 { allowedHosts, authReason, DEFAULT_SETTINGS } from './config.js';
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
- // The self-hosted gate. It is authentication, unlike the two guards
195
- // above: the mode says the network can reach us, so every API call
196
- // proves it holds the access key. `/api/health` stays open — a tunnel
197
- // or uptime check has no key and tells an attacker nothing.
198
- if (found && url.pathname.startsWith('/api/') && url.pathname !== '/api/health') {
199
- const denied = authReason(req, settings);
200
- if (denied) return sendError(res, 401, denied);
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);