codebase-onboarder 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.';
@@ -0,0 +1,195 @@
1
+ // Running the server without the terminal that asked for it.
2
+ //
3
+ // `onboarder start` keeps the process attached: closing the shell kills it, and
4
+ // that is the right behavior for a foreground command. `onboarder start
5
+ // background` wants the opposite — the whole point is that the terminal can go
6
+ // away — so this module does what a shell job control cannot do portably: it
7
+ // re-launches the CLI as a **detached** child with its stdio pointed at a log
8
+ // file, unrefs it, and then waits for the port to actually answer before it
9
+ // claims success.
10
+ //
11
+ // Two rules keep this honest:
12
+ //
13
+ // 1. Never report "running" on the strength of the spawn alone. `spawn`
14
+ // returning a pid proves a process was created, not that it bound the port
15
+ // or survived settings validation. Readiness is an HTTP answer from
16
+ // `/api/health`, polled with a deadline; on timeout the caller gets the
17
+ // child's last log lines, not a false green light.
18
+ // 2. Detach properly, or "background" is a lie. `detached: true` puts the
19
+ // child in its own process group, and `unref()` drops our handle on it, so
20
+ // a Ctrl-C in the parent terminal does not take the server down with it.
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import fsp from 'node:fs/promises';
25
+ import http from 'node:http';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ import { configPath } from './config.js';
30
+ import { readPidFile } from './pidfile.js';
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ // The installed entry point, not `process.argv[1]`: a globally installed
35
+ // `onboarder` is a symlink into node_modules, and the child has to run the real
36
+ // file to find its siblings.
37
+ export const CLI_ENTRY = path.resolve(HERE, '..', 'bin', 'onboarder.js');
38
+
39
+ // One file per config, beside the config, so `--config` isolation (tests,
40
+ // containers, several profiles) carries the log with it.
41
+ export function logPath(configFile = configPath()) {
42
+ return path.join(path.dirname(path.resolve(configFile)), 'onboarder.log');
43
+ }
44
+
45
+ // Rotated history. One generation is deliberate: a log that grows without bound
46
+ // is a bug report waiting to happen, and one previous file is enough to see what
47
+ // happened just before a crash.
48
+ export const MAX_LOG_BYTES = 2 * 1024 * 1024;
49
+
50
+ export async function rotateLogIfNeeded(file = logPath(), maxBytes = MAX_LOG_BYTES) {
51
+ try {
52
+ const { size } = await fsp.stat(file);
53
+ if (size <= maxBytes) return false;
54
+ await fsp.rename(file, file + '.1');
55
+ return true;
56
+ } catch {
57
+ return false; // no log yet, or not ours to move
58
+ }
59
+ }
60
+
61
+ export async function readLog(file = logPath(), bytes = 64 * 1024) {
62
+ try {
63
+ const handle = await fsp.open(file, 'r');
64
+ try {
65
+ const { size } = await handle.stat();
66
+ const start = Math.max(0, size - bytes);
67
+ const buffer = Buffer.alloc(size - start);
68
+ await handle.read(buffer, 0, buffer.length, start);
69
+ return buffer.toString('utf8');
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+ } catch {
74
+ return '';
75
+ }
76
+ }
77
+
78
+ // The last `count` lines, oldest first — the shape `onboarder logs` prints and
79
+ // the shape an error report wants pasted into it.
80
+ export async function tailLog(file = logPath(), count = 40) {
81
+ const text = await readLog(file);
82
+ const lines = text.split('\n').filter((line) => line.trim());
83
+ return lines.slice(-Math.max(1, count));
84
+ }
85
+
86
+ export async function logExists(file = logPath()) {
87
+ try { await fsp.access(file); return true; } catch { return false; }
88
+ }
89
+
90
+ // Ask the server whether it is up. A 2xx–4xx from /api/health means the socket
91
+ // is bound and the router is serving; the body is not interesting, the answer is.
92
+ export function probe(url, timeoutMs = 1000) {
93
+ return new Promise((resolve) => {
94
+ const request = http.get(url, { timeout: timeoutMs }, (res) => {
95
+ res.resume();
96
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
97
+ });
98
+ request.on('timeout', () => { request.destroy(); resolve(false); });
99
+ request.on('error', () => resolve(false));
100
+ });
101
+ }
102
+
103
+ export async function waitForReady(url, { timeoutMs = 20000, intervalMs = 200, check = probe } = {}) {
104
+ const deadline = Date.now() + timeoutMs;
105
+ for (;;) {
106
+ if (await check(url)) return true;
107
+ if (Date.now() >= deadline) return false;
108
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
109
+ }
110
+ }
111
+
112
+ // Re-run this same CLI, detached. The child is a plain foreground `start` — it
113
+ // writes the pid file, prints its own banner, and handles signals exactly as it
114
+ // always has. Nothing about the server changes; only who is holding the terminal
115
+ // does.
116
+ export function spawnDetached({ configFile = configPath(), entry = CLI_ENTRY, env = process.env, log = logPath(configFile) } = {}) {
117
+ const fd = fs.openSync(log, 'a');
118
+ try {
119
+ const child = spawn(process.execPath, [entry, 'start', '--config', configFile], {
120
+ detached: true,
121
+ stdio: ['ignore', fd, fd],
122
+ env: { ...env, ONBOARDER_BACKGROUND: '1' },
123
+ });
124
+ child.on('error', () => {});
125
+ child.unref();
126
+ return child.pid;
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+
132
+ // Does the OS still have this process? Signal 0 asks without delivering.
133
+ function alive(pid) {
134
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
135
+ }
136
+
137
+ // Wait for the detached child to become the *recorded* server. The pid file is
138
+ // the authority here, not the spawn pid: it is what `status` and `stop` read,
139
+ // so waiting on it means the process we report is the one the user can control.
140
+ export async function waitForPidFile(configFile, { timeoutMs = 20000, intervalMs = 150, readPid = readPidFile, isAlive = alive } = {}) {
141
+ const deadline = Date.now() + timeoutMs;
142
+ for (;;) {
143
+ const pid = readPid(configFile);
144
+ if (pid && isAlive(pid)) return pid;
145
+ if (Date.now() >= deadline) return null;
146
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
147
+ }
148
+ }
149
+
150
+ // Follow a growing file. Polling rather than fs.watch: the log is appended by a
151
+ // *different* process, and watchers on a file another process holds open are
152
+ // unreliable across platforms (and absent on some network mounts). Half a second
153
+ // of latency on a human-facing log tail is invisible.
154
+ export function followLog(file, onLine, { intervalMs = 500, from = 'end' } = {}) {
155
+ let position = 0;
156
+ let partial = '';
157
+ let stopped = false;
158
+ const stop = () => { stopped = true; };
159
+
160
+ if (from === 'start') {
161
+ fsp.readFile(file, 'utf8').then(
162
+ (text) => { for (const line of text.split('\n')) if (line) onLine(line); },
163
+ () => {},
164
+ );
165
+ }
166
+
167
+ const tick = async () => {
168
+ if (stopped) return;
169
+ try {
170
+ const { size } = await fsp.stat(file);
171
+ if (size < position) { position = 0; partial = ''; } // rotated under us
172
+ if (size > position) {
173
+ const handle = await fsp.open(file, 'r');
174
+ try {
175
+ const length = size - position;
176
+ const buffer = Buffer.alloc(length);
177
+ await handle.read(buffer, 0, length, position);
178
+ position = size;
179
+ // A read can land mid-line; hold the remainder until its newline shows.
180
+ const lines = (partial + buffer.toString('utf8')).split('\n');
181
+ partial = lines.pop() ?? '';
182
+ for (const line of lines) onLine(line);
183
+ } finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+ } catch {
188
+ // The file may not exist yet (first run) — try again on the next tick.
189
+ }
190
+ if (!stopped) setTimeout(tick, intervalMs).unref();
191
+ };
192
+
193
+ setTimeout(tick, intervalMs).unref();
194
+ return stop;
195
+ }