codebase-onboarder 0.2.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/server/index.js CHANGED
@@ -21,7 +21,7 @@ import { createRouter } from './router.js';
21
21
  import { installExitCleanup } from './sessions.js';
22
22
  import { createLogger } from './logger.js';
23
23
  import { createMcpRunner } from './mcp/runner.js';
24
- import { configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
24
+ import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
25
25
  import { tunnelStatus } from './tunnel.js';
26
26
  import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
27
27
 
@@ -73,7 +73,17 @@ export function startupBanner(settings, { configFile } = {}) {
73
73
  : ' Mode local — only this machine can reach it');
74
74
  lines.push(' Local ' + urls.local);
75
75
  if (urls.network) lines.push(' Network ' + urls.network);
76
- if (urls.domain) lines.push(' Domain ' + urls.domain);
76
+ if (urls.domain) {
77
+ lines.push(' Domain ' + urls.domain);
78
+ if (settings.domain && !settings.https) {
79
+ lines.push(' HTTPS disabled — `onboarder setup` or `onboarder https setup` enables trusted TLS');
80
+ } else if (settings.https) {
81
+ lines.push(' HTTPS Caddy obtains, renews, and terminates TLS for this domain');
82
+ }
83
+ }
84
+ if (settings.mode === 'self-hosted' && settings.accessKey) {
85
+ lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
86
+ }
77
87
  if (settings.mode === 'self-hosted' && !settings.accessKey) {
78
88
  lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
79
89
  lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
@@ -130,7 +140,7 @@ export async function startServer({ configFile = configPath(), openBrowser, log
130
140
  ...CONFIG,
131
141
  configPath: configFile,
132
142
  getSettings: () => readSettings(configFile),
133
- boot: { host, port },
143
+ boot: { host, port, domain: settings.domain, https: settings.https },
134
144
  });
135
145
 
136
146
  installExitCleanup();
@@ -166,9 +176,9 @@ export async function startServer({ configFile = configPath(), openBrowser, log
166
176
  log(startupBanner(live, { configFile }));
167
177
 
168
178
  const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
169
- // Remote visitors still need the key; what opens locally is the loopback URL,
170
- // which in local mode needs nothing and in self-hosted mode asks for the key.
171
- if (shouldOpen) openInBrowser(serverUrls(live).local);
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
+ if (shouldOpen) openInBrowser(browserUrl(live));
172
182
  return { server, settings: live, host, port };
173
183
  }
174
184
 
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);
package/server/tunnel.js CHANGED
@@ -50,6 +50,11 @@ export function tailscaleCommand(settings) {
50
50
  }
51
51
 
52
52
  export function installHint(name) {
53
+ if (name === 'caddy') {
54
+ return process.platform === 'darwin'
55
+ ? 'brew install caddy'
56
+ : 'Install Caddy with: sudo apt install caddy';
57
+ }
53
58
  if (name === 'cloudflared') {
54
59
  return process.platform === 'darwin'
55
60
  ? 'brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)'