tinywebui 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ It comes pre-loaded with two TinySuite MCP servers, so a new install can already
47
47
  - **Hybrid local search** (BM25 plus a small on-device embedding model) over documents and past chats.
48
48
  - **Long chats that stay affordable.** Cache-friendly prefixes, one-time compaction of large tool results, and per-round token statistics.
49
49
  - **Automations.** Cron-scheduled prompts that run in a chat of your choice.
50
- - **Private by default.** Interactive first runs ask for an owner password; a team can use an SSO gateway with roles and an admin panel.
50
+ - **Private by default.** Local starts sign in with a one-time link from the terminal, network binds need a password, and a team can use an SSO gateway with roles and an admin panel.
51
51
  - **Managed from files.** Version-controlled config that hot-reloads, refuses typos and has a fingerprint you can check against a running instance.
52
52
  - **Themes.** Fall Fairy and Cyber Grid are included. See [theme authoring](public/themes/README.md) to write your own.
53
53
 
@@ -59,7 +59,7 @@ Requires **Node.js 22.13** or later. The bundled TinySearch and TinyContext serv
59
59
  npx tinywebui
60
60
  ```
61
61
 
62
- The first run asks you to create an owner password of at least 15 characters. Open <http://127.0.0.1:7777>, sign in, then connect a model. Put an endpoint and key in `tinywebui.config.json` (start from `example.tinywebui.config.json`) or use the Settings panel:
62
+ There is no password to set up. The terminal prints a sign-in link (`http://127.0.0.1:7777/#token=…`); open it and the browser stays signed in. Only someone who can read that terminal gets in. Then connect a model. Put an endpoint and key in `tinywebui.config.json` (start from `example.tinywebui.config.json`) or use the Settings panel:
63
63
 
64
64
  ```json
65
65
  {
@@ -69,7 +69,7 @@ The first run asks you to create an owner password of at least 15 characters. Op
69
69
  }
70
70
  ```
71
71
 
72
- `tinywebui.config.json` holds your password hash and API keys and is gitignored. Keep it that way. Use `npx tinywebui set-password` to change the password later.
72
+ `tinywebui.config.json` holds your API keys (and password hash, if you set one) and is gitignored. Keep it that way. Run `npx tinywebui set-password` to sign in with a password instead of the link; you need one to listen on anything other than loopback.
73
73
 
74
74
  `npx tinywebui` uses keyword search until you run `npx tinywebui models pull fast` (about 90 MB) to enable hybrid search.
75
75
 
package/bin/tinywebui.js CHANGED
@@ -91,16 +91,6 @@ try {
91
91
  console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
92
92
  process.exit(1);
93
93
  }
94
- const { createConfigSource } = await import('../src/config/config.js');
95
- const initial = createConfigSource(opts).load({ persistSecret: false });
96
- if (initial.authMode === 'single' && !initial.authPassword && !process.env.TINYWEBUI_PASSWORD) {
97
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
98
- throw new Error('owner password required: run `tinywebui set-password` in a terminal, or set TINYWEBUI_PASSWORD before starting');
99
- }
100
- const { setPassword } = await import('../src/set-password.js');
101
- console.log('[tinywebui] Set an owner password before the first start.');
102
- await setPassword(opts);
103
- }
104
94
  const { start } = await import('../src/server.js');
105
95
  // Command-line flags beat the file; the file beats the built-in defaults.
106
96
  await start({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinywebui",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A small, local-first chat workspace for any OpenAI-compatible model, with MCP tools.",
5
5
  "type": "module",
6
6
  "repository": {
package/public/app.js CHANGED
@@ -24,12 +24,32 @@ import { initAutomations, loadAutomations } from './panels/automations.js';
24
24
  import { loadUsage } from './panels/usage.js';
25
25
  import { initAdmin, loadAdmin } from './panels/admin.js';
26
26
 
27
- /** 'single' mode with no session: show the password screen and stop there. */
27
+ /**
28
+ * 'single' mode with no session: trade a launch link's token for a session,
29
+ * or show the sign-in screen and stop there.
30
+ */
28
31
  async function needsLogin() {
32
+ const token = new URLSearchParams(location.hash.slice(1)).get('token');
33
+ // Off the address bar first, so it isn't bookmarked or shared by accident.
34
+ if (token) history.replaceState(null, '', location.pathname + location.search);
29
35
  const me = await loadAccess();
30
36
  if (me.authMode !== 'single' || me.user) return false;
37
+ if (token && me.login === 'link') {
38
+ try {
39
+ await api.post('/api/auth/login', { token });
40
+ location.reload();
41
+ return true;
42
+ } catch { /* stale link: fall through to the sign-in screen */ }
43
+ }
31
44
  const box = $('login');
32
45
  box.hidden = false;
46
+ if (me.login === 'link') {
47
+ $('loginForm').hidden = true;
48
+ $('loginLink').hidden = false;
49
+ // The link pasted into this same tab only changes the hash: start over.
50
+ addEventListener('hashchange', () => location.reload());
51
+ return true;
52
+ }
33
53
  $('loginPassword').focus();
34
54
  $('loginForm').onsubmit = async (e) => {
35
55
  e.preventDefault();
package/public/index.html CHANGED
@@ -238,6 +238,10 @@
238
238
  <button class="primary" type="submit">Sign in</button>
239
239
  <div id="loginMsg" class="usage" role="alert"></div>
240
240
  </form>
241
+ <div id="loginLink" class="login-card" hidden>
242
+ <h2>TinyWebUI</h2>
243
+ <p>Open the sign-in link printed in the terminal where TinyWebUI is running.</p>
244
+ </div>
241
245
  </div>
242
246
 
243
247
  <section id="admin" aria-label="Admin">
package/public/styles.css CHANGED
@@ -295,7 +295,7 @@ main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height:
295
295
  #login[hidden] { display:none; }
296
296
  .login-card { width:min(22rem,100%); display:flex; flex-direction:column; gap:.6rem; padding:1.5rem; border:1px solid var(--line); border-radius:var(--r-md); background:var(--raise); }
297
297
  .login-card h2 { margin:0 0 .5rem; }
298
- #loginMsg:empty { display:none; }
298
+ #loginMsg:empty, .login-card[hidden] { display:none; }
299
299
  .admin-users { display:flex; flex-direction:column; gap:.4rem; }
300
300
  .admin-row { display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; padding:.5rem 0; border-top:1px solid var(--line); font-size:var(--fs-meta); }
301
301
  .admin-row:first-child { border-top:0; }
@@ -40,7 +40,7 @@ const DEFAULT_ROLES = {
40
40
  export const FILE_ONLY = new Set([
41
41
  'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
42
42
  'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
43
- 'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'frozen', 'autoMigrate', 'retrieval',
43
+ 'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'allowedHosts', 'frozen', 'autoMigrate', 'retrieval',
44
44
  'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
45
45
  ]);
46
46
 
@@ -118,9 +118,7 @@ export function validateConfig(cfg, { env = process.env } = {}) {
118
118
  if (mode === 'single') {
119
119
  if (cfg.authPassword && !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/i.test(String(cfg.authPassword))) {
120
120
  errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
121
- } else if (!cfg.authPassword && !env.TINYWEBUI_PASSWORD) {
122
- errors.push("authMode 'single' needs a password: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
123
- } else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
121
+ } else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
124
122
  errors.push(`TINYWEBUI_PASSWORD needs at least ${MIN_PASSWORD_LENGTH} characters`);
125
123
  }
126
124
  }
@@ -179,6 +179,9 @@ export const DEFAULTS = {
179
179
  // Extra origins allowed to send state-changing requests (e.g. an admin
180
180
  // tool on another host). The page's own origin is always allowed.
181
181
  allowedOrigins: [],
182
+ // Extra Host names answered when authMode is 'none' (e.g. a LAN name for a
183
+ // local-only install). Loopback names are always allowed.
184
+ allowedHosts: [],
182
185
  // false: refuse to start on a database that needs migrating; run
183
186
  // `tinywebui migrate` as its own deployment step instead.
184
187
  autoMigrate: true,
package/src/http.js CHANGED
@@ -75,6 +75,20 @@ export function crossSite(req, cfg) {
75
75
  return req.headers['sec-fetch-site'] === 'cross-site';
76
76
  }
77
77
 
78
+ /**
79
+ * DNS-rebinding guard. With no login, a hostile site can point its own name at
80
+ * 127.0.0.1 and pass the same-origin check above (Origin and Host both name it),
81
+ * so a no-login install only answers to loopback names and allowedHosts.
82
+ * Logged-in modes are safe: the attacker's origin has no session cookie.
83
+ */
84
+ export function badHost(req, cfg) {
85
+ if (cfg.authMode !== 'none') return false;
86
+ let hostname;
87
+ try { hostname = new URL(`http://${req.headers.host}`).hostname.toLowerCase(); } catch { return true; }
88
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]') return false;
89
+ return !(cfg.allowedHosts || []).some((h) => h.toLowerCase() === hostname);
90
+ }
91
+
78
92
  export async function serveStatic(req, res) {
79
93
  // Resolve the index BEFORE normalising: on Windows normalize('/') returns a
80
94
  // lone backslash, so a check for '/' after it never matches and the root
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { json, readJson } from '../http.js';
2
3
  import { isClosed, findEntry, labelFor } from '../config/models.js';
3
4
  import { modelsFor, fingerprint, resolveAccess } from '../access/policy.js';
@@ -12,7 +13,12 @@ import { FEATURE } from '../../public/shared/features.js';
12
13
  const LOGIN_MAX = 5;
13
14
  const LOGIN_WINDOW_MS = 15 * 60_000;
14
15
 
15
- export function authRoutes({ config, modelFor, multiUser, passwordHash, source, store, version }) {
16
+ const sameSecret = (given, expected) => {
17
+ const a = Buffer.from(String(given ?? '')), b = Buffer.from(expected);
18
+ return a.length === b.length && timingSafeEqual(a, b);
19
+ };
20
+
21
+ export function authRoutes({ config, modelFor, multiUser, passwordHash, launchToken, source, store, version }) {
16
22
  const loginFailures = new Map();
17
23
 
18
24
  return [
@@ -22,6 +28,8 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
22
28
  const model = signedIn ? modelFor(auth.user?.id, auth.role) : null;
23
29
  return json(res, 200, {
24
30
  authMode: cfg.authMode,
31
+ // How 'single' signs in: a password, or the link printed at startup.
32
+ login: cfg.authMode === 'single' ? (passwordHash() ? 'password' : 'link') : null,
25
33
  logoutUrl: cfg.logoutUrl || '',
26
34
  isAdmin: Boolean(auth.isAdmin),
27
35
  // What this person may personalise, and their saved instructions (multi-user only).
@@ -50,11 +58,14 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
50
58
  if (entry && entry.count >= LOGIN_MAX) {
51
59
  return json(res, 429, { error: 'too many attempts, try again later' });
52
60
  }
53
- const { password } = await readJson(req);
54
- if (!verifyPassword(password, passwordHash())) {
61
+ const { password, token: given } = await readJson(req);
62
+ const hash = passwordHash();
63
+ // The launch link only stands in for a password that doesn't exist.
64
+ const ok = hash ? verifyPassword(password, hash) : Boolean(launchToken()) && sameSecret(given, launchToken());
65
+ if (!ok) {
55
66
  loginFailures.set(who, { first: entry?.first ?? now, count: (entry?.count ?? 0) + 1 });
56
- audit('auth.rejected', { reason: 'bad_password' });
57
- return json(res, 401, { error: 'wrong password' });
67
+ audit('auth.rejected', { reason: hash ? 'bad_password' : 'bad_launch_token' });
68
+ return json(res, 401, { error: hash ? 'wrong password' : 'open the sign-in link printed in the terminal' });
58
69
  }
59
70
  loginFailures.delete(who);
60
71
  const token = createSession(store, OWNER_ID, cfg.sessionTtlDays);
package/src/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { watch, existsSync } from 'node:fs';
4
5
  import { dirname } from 'node:path';
@@ -12,7 +13,7 @@ import { hashPassword, applyAccessPolicy } from './access/auth.js';
12
13
  import { audit } from './audit.js';
13
14
  import { logger } from './log.js';
14
15
  import { resolveAuth, OWNER_ID } from './access/auth_gate.js';
15
- import { json, HttpError, SECURITY_HEADERS, crossSite, serveStatic } from './http.js';
16
+ import { json, HttpError, SECURITY_HEADERS, crossSite, badHost, serveStatic } from './http.js';
16
17
  import { expandToolDef, callExpand } from './tools/context_tool.js';
17
18
  import { documentToolDef, callReadDocument } from './tools/document_tool.js';
18
19
  import { askToolDef, callAskUser } from './tools/ask_tool.js';
@@ -53,7 +54,7 @@ function routeTable(app) {
53
54
  const deps = {
54
55
  store: app.store, runs: app.runs, scheduler: app.scheduler, retrieval: app.retrieval,
55
56
  source: app.source, version: app.version,
56
- config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash,
57
+ config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash, launchToken: () => app.launchToken,
57
58
  saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
58
59
  auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
59
60
  multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
@@ -73,6 +74,12 @@ export const API_ROUTES = routeTable({}).map((r) => [r.method, r.path, r.feature
73
74
  // trust boundary sits.
74
75
  const ADMIN_ONLY_FIELDS = /^(apiKey|keyHint|connectors|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
75
76
 
77
+ const LOOPBACK_BINDS = ['127.0.0.1', '::1', 'localhost'];
78
+
79
+ // 'single': the stored hash, or one made in memory from $TINYWEBUI_PASSWORD
80
+ // (handy for containers); null means sign-in is by launch link only.
81
+ const ownerHash = (cfg) => cfg.authPassword || (process.env.TINYWEBUI_PASSWORD ? hashPassword(process.env.TINYWEBUI_PASSWORD) : null);
82
+
76
83
  /**
77
84
  * Starts an instance. Everything but port/host is optional and passed to
78
85
  * createConfigSource: `config` (keys set here win and are locked in the UI),
@@ -83,9 +90,13 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
83
90
  const initial = source.load();
84
91
  const problems = configProblems(initial);
85
92
  if (problems.length) throw new Error(problems.join('\n'));
86
- if (initial.authMode === 'none' && !['127.0.0.1', '::1', 'localhost'].includes(host)) {
93
+ const loopback = LOOPBACK_BINDS.includes(host);
94
+ if (initial.authMode === 'none' && !loopback) {
87
95
  throw new Error("authMode 'none' is limited to loopback; use password login or a trusted-header gateway for network access");
88
96
  }
97
+ if (initial.authMode === 'single' && !ownerHash(initial) && !loopback) {
98
+ throw new Error("authMode 'single' needs a password on a network bind: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
99
+ }
89
100
 
90
101
  const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
91
102
  // Runs live in this process, so nothing from before it can still be waiting.
@@ -121,9 +132,12 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
121
132
  retrieval: new Retrieval(store, { ...initial.retrieval, mode: resolved.mode }, embedder),
122
133
  cfg: initial,
123
134
  hub: null,
124
- // 'single': the stored hash, or one made in memory from $TINYWEBUI_PASSWORD
125
- // (handy for containers). validateConfig already refused a plaintext one.
126
- passwordHash: initial.authMode === 'single' ? initial.authPassword || hashPassword(process.env.TINYWEBUI_PASSWORD) : null,
135
+ passwordHash: initial.authMode === 'single' ? ownerHash(initial) : null,
136
+ // A passwordless loopback install signs in with this instead: printed as a
137
+ // link at startup, so only someone who can read this terminal gets in.
138
+ // Made whenever it could be needed, so a reload that drops the password
139
+ // still leaves a way in.
140
+ launchToken: initial.authMode === 'single' && loopback ? randomBytes(32).toString('hex') : null,
127
141
 
128
142
  // Every UI/API change lands in config.json (source.save) and is audited:
129
143
  // who, which keys, the new values (none of these keys are secrets), and the
@@ -321,6 +335,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
321
335
  for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
322
336
  const url = new URL(req.url || '/', 'http://x');
323
337
  const { pathname } = url;
338
+ if (badHost(req, app.cfg)) {
339
+ audit('request.bad_host_rejected', { host: String(req.headers.host).slice(0, 100), path: pathname });
340
+ return json(res, 403, { error: 'unknown host' });
341
+ }
324
342
  if (crossSite(req, app.cfg)) {
325
343
  audit('request.cross_site_rejected', { method: req.method, path: pathname });
326
344
  return json(res, 403, { error: 'cross-site request refused' });
@@ -358,7 +376,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
358
376
 
359
377
  await new Promise((resolve) => server.listen(port, host, resolve));
360
378
  scheduler.arm();
361
- log.info(`http://${host}:${port}`);
379
+ const origin = `http://${host.includes(':') ? `[${host}]` : host}:${server.address().port}`;
380
+ // In the fragment, so it never reaches a server log or a Referer header.
381
+ app.launchUrl = app.launchToken ? `${origin}/#token=${app.launchToken}` : origin;
382
+ log.info(app.launchToken && !app.passwordHash ? `sign in: ${app.launchUrl}` : origin);
362
383
  // Anything stored before dense retrieval was on (or under another model)
363
384
  // is embedded in the background; document queries fill in on demand too.
364
385
  retrieval.backfill((msg) => log.info(`retrieval: ${msg}`))
@@ -399,7 +420,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
399
420
  const mcpChanged = text !== mcpText;
400
421
  if (before === after && !mcpChanged) return true; // our own write, or a no-op save
401
422
  app.cfg = next;
402
- if (next.authMode === 'single') app.passwordHash = next.authPassword || hashPassword(process.env.TINYWEBUI_PASSWORD);
423
+ if (next.authMode === 'single') {
424
+ app.passwordHash = ownerHash(next);
425
+ if (!app.passwordHash) log.info(`sign in: ${app.launchUrl}`);
426
+ }
403
427
  if (next.authMode === 'trusted-header') applyAccessPolicy(store, next);
404
428
  if (mcpChanged) await app.swapHub(servers);
405
429
  scheduler.arm();
@@ -432,6 +456,7 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
432
456
  const onHup = () => scheduleReload('SIGHUP');
433
457
  process.on('SIGHUP', onHup);
434
458
  server.reload = reloadFromFiles;
459
+ server.launchUrl = app.launchUrl;
435
460
 
436
461
  // Real teardown, reused two ways: on SIGINT/SIGTERM it exits the process, and
437
462
  // as `server.shutdown()` it does not -- which is what a test harness needs.