tinywebui 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,6 @@
1
+ {
2
+ "themes": [
3
+ { "id": "fall-fairy", "name": "Fall Fairy", "file": "fall-fairy/fall-fairy.css", "js": "fall-fairy/fall-fairy.js" },
4
+ { "id": "cyber-grid", "name": "Cyber Grid", "file": "cyber-grid/cyber-grid.css" }
5
+ ]
6
+ }
@@ -0,0 +1,123 @@
1
+ /*
2
+ * Theme loader — the only bridge between the core app and public/themes/.
3
+ * Skins are pure CSS files that redefine the variables styles.css already
4
+ * reads; this file just knows how to list them, apply one, and remember the
5
+ * choice. Adding a skin means dropping a CSS file here and adding one line
6
+ * to manifest.json — nothing in app.js or styles.css changes.
7
+ *
8
+ * A skin may also name a `js` module. That module is the skin's own scenery:
9
+ * it gets mounted when the skin is selected and unmounted when it is not, and
10
+ * it is expected to keep to the same contract the CSS does — decorative,
11
+ * pointer-events:none, nothing that reads or writes app state. The loader is
12
+ * the only place that knows such modules exist, so a skin with one is still
13
+ * just a file in this directory.
14
+ */
15
+
16
+ const KEY = 'tinywebui.theme';
17
+ const LINK_ID = 'theme-css';
18
+
19
+ /** The mounted scenery module's teardown, if the current skin brought one. */
20
+ let unmountScene = null;
21
+
22
+ async function applyTheme(id, themes) {
23
+ document.documentElement.dataset.theme = id || '';
24
+
25
+ // Whatever the last skin put on the page goes first, before its CSS does:
26
+ // a scene left running against another skin's palette is the one failure
27
+ // mode this bridge can actually cause.
28
+ if (unmountScene) {
29
+ try { unmountScene(); } catch { /* a broken skin must not wedge the picker */ }
30
+ unmountScene = null;
31
+ }
32
+
33
+ let link = document.getElementById(LINK_ID);
34
+ const t = themes.find((x) => x.id === id);
35
+ if (!t) {
36
+ if (link) link.remove();
37
+ return;
38
+ }
39
+ if (!link) {
40
+ link = document.createElement('link');
41
+ link.id = LINK_ID;
42
+ link.rel = 'stylesheet';
43
+ document.head.appendChild(link);
44
+ }
45
+ const loaded = cssLoaded(link, `themes/${t.file}`);
46
+ link.href = `themes/${t.file}`;
47
+
48
+ if (!t.js) return;
49
+ try {
50
+ // The scene reads its palette out of the skin's own custom properties, so
51
+ // it cannot start until that stylesheet is actually applied. Setting href
52
+ // and importing in the same breath loses that race: the module resolves
53
+ // first, every variable reads back empty, and the scene starts against a
54
+ // palette that does not exist yet.
55
+ await loaded;
56
+ const mod = await import(`./${t.js}`);
57
+ // The skin may have been switched again while its module was loading.
58
+ if (document.documentElement.dataset.theme !== id) return;
59
+ unmountScene = (await mod.mount?.()) || mod.unmount || null;
60
+ } catch (err) {
61
+ // Scenery is decoration — the skin still works as plain CSS — but a failure
62
+ // that leaves no trace anywhere is the one that costs an afternoon.
63
+ console.warn(`[theme] "${id}" scenery did not start:`, err);
64
+ }
65
+ }
66
+
67
+ /** Resolves when the stylesheet at `href` has applied, or when waiting any
68
+ * longer stops being worth it. A skin must never hang on its own decoration,
69
+ * so this resolves rather than rejects on error or timeout. */
70
+ function cssLoaded(link, href) {
71
+ return new Promise((resolve) => {
72
+ let done = false;
73
+ const finish = () => { if (!done) { done = true; resolve(); } };
74
+ link.addEventListener('load', finish, { once: true });
75
+ link.addEventListener('error', finish, { once: true });
76
+ // A stylesheet already in the cache can fire load before we listen, and a
77
+ // slow one must not hold the scene hostage.
78
+ setTimeout(finish, 2000);
79
+ queueMicrotask(() => {
80
+ for (const sheet of document.styleSheets) {
81
+ if (sheet.href && sheet.href.endsWith(href)) finish();
82
+ }
83
+ });
84
+ });
85
+ }
86
+
87
+ function savedTheme() {
88
+ try { return localStorage.getItem(KEY) || ''; } catch { return ''; }
89
+ }
90
+
91
+ function saveTheme(id) {
92
+ try {
93
+ if (id) localStorage.setItem(KEY, id);
94
+ else localStorage.removeItem(KEY);
95
+ } catch { /* per-viewer convenience only */ }
96
+ }
97
+
98
+ async function init() {
99
+ let themes = [];
100
+ try {
101
+ const res = await fetch('themes/manifest.json');
102
+ themes = (await res.json()).themes || [];
103
+ } catch { /* no manifest, no skins available this session */ }
104
+
105
+ const current = savedTheme();
106
+ applyTheme(current, themes);
107
+
108
+ const select = document.getElementById('theme');
109
+ if (!select) return;
110
+ for (const t of themes) {
111
+ const opt = document.createElement('option');
112
+ opt.value = t.id;
113
+ opt.textContent = t.name;
114
+ select.appendChild(opt);
115
+ }
116
+ select.value = current;
117
+ select.addEventListener('change', () => {
118
+ saveTheme(select.value);
119
+ applyTheme(select.value, themes);
120
+ });
121
+ }
122
+
123
+ init();
@@ -0,0 +1,228 @@
1
+ import { randomBytes, createHmac, createHash, timingSafeEqual, scryptSync } from 'node:crypto';
2
+ import { BlockList, isIP } from 'node:net';
3
+ import { resolveAccess } from './policy.js';
4
+ import { audit } from '../audit.js';
5
+
6
+ /**
7
+ * Session/cookie plumbing for authMode 'single' (password login), plus the
8
+ * identity resolution for 'trusted-header' further down.
9
+ * No framework, no dependency: a signed HTTP-only cookie names a session
10
+ * token, and the token itself is looked up (by its hash, never the raw
11
+ * value) against the `sessions` table. When authMode is 'none' none of this
12
+ * is ever called -- see auth_gate.js.
13
+ */
14
+
15
+ const COOKIE_NAME = 'tinywebui_session';
16
+
17
+ function sha256(value) {
18
+ return createHash('sha256').update(value).digest('hex');
19
+ }
20
+
21
+ /** HMAC-signs a value so a client can hold it without being able to forge it. */
22
+ function sign(value, secret) {
23
+ return createHmac('sha256', secret).update(value).digest('hex');
24
+ }
25
+
26
+ function verify(value, sig, secret) {
27
+ const expected = sign(value, secret);
28
+ const a = Buffer.from(sig, 'hex');
29
+ const b = Buffer.from(expected, 'hex');
30
+ return a.length === b.length && timingSafeEqual(a, b);
31
+ }
32
+
33
+ export function createSession(store, userId, ttlDays = 30) {
34
+ const token = randomBytes(32).toString('hex');
35
+ store.users.addSession(sha256(token), userId, Date.now() + ttlDays * 86400_000);
36
+ return token;
37
+ }
38
+
39
+ export function destroySession(store, token) {
40
+ store.users.deleteSession(sha256(token));
41
+ }
42
+
43
+ export function destroyUserSessions(store, userId) {
44
+ store.users.deleteUserSessions(userId);
45
+ }
46
+
47
+ /** Builds the Set-Cookie header value for a freshly created session token. */
48
+ export function sessionCookie(token, secret, { secure = false } = {}) {
49
+ const signature = sign(token, secret);
50
+ const parts = [
51
+ `${COOKIE_NAME}=${token}.${signature}`,
52
+ 'Path=/', 'HttpOnly', 'SameSite=Lax',
53
+ ];
54
+ if (secure) parts.push('Secure');
55
+ return parts.join('; ');
56
+ }
57
+
58
+ export function clearCookie({ secure = false } = {}) {
59
+ const parts = [`${COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
60
+ if (secure) parts.push('Secure');
61
+ return parts.join('; ');
62
+ }
63
+
64
+ /* ---------- password (authMode 'single') ---------- */
65
+
66
+ const SCRYPT = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
67
+
68
+ /** `scrypt$<salt hex>$<hash hex>` -- what authPassword holds. Never plaintext. */
69
+ export function hashPassword(password) {
70
+ const salt = randomBytes(16);
71
+ const hash = scryptSync(String(password), salt, 32, SCRYPT);
72
+ return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
73
+ }
74
+
75
+ export function verifyPassword(password, stored) {
76
+ const [kind, saltHex, hashHex] = String(stored || '').split('$');
77
+ if (kind !== 'scrypt' || !saltHex || !hashHex) return false;
78
+ const expected = Buffer.from(hashHex, 'hex');
79
+ const actual = scryptSync(String(password ?? ''), Buffer.from(saltHex, 'hex'), expected.length, SCRYPT);
80
+ return timingSafeEqual(actual, expected);
81
+ }
82
+
83
+ /** The raw session token from a validly signed cookie, or null. */
84
+ export function sessionToken(req, cfg) {
85
+ const raw = readCookie(req);
86
+ if (!raw) return null;
87
+ const dot = raw.lastIndexOf('.');
88
+ if (dot === -1) return null;
89
+ const token = raw.slice(0, dot);
90
+ return cfg.sessionSecret && verify(token, raw.slice(dot + 1), cfg.sessionSecret) ? token : null;
91
+ }
92
+
93
+ function readCookie(req) {
94
+ const header = req.headers.cookie;
95
+ if (!header) return null;
96
+ for (const part of header.split(';')) {
97
+ const idx = part.indexOf('=');
98
+ if (idx === -1) continue;
99
+ const name = part.slice(0, idx).trim();
100
+ if (name === COOKIE_NAME) return part.slice(idx + 1).trim();
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** Whether the connection looks TLS-terminated, for the cookie's Secure flag. */
106
+ export function isSecureRequest(req, trustedProxyCidrs = []) {
107
+ if (req.socket?.encrypted === true) return true;
108
+ // X-Forwarded-Proto is a claim only a known proxy may make.
109
+ return req.headers['x-forwarded-proto'] === 'https' && ipInCidrs(req.socket?.remoteAddress, trustedProxyCidrs);
110
+ }
111
+
112
+ /**
113
+ * Resolves the logged-in user for a request, or null. Verifies the cookie's
114
+ * signature first (cheap, rejects tampering/forgery), then looks up the
115
+ * token's hash in `sessions` and checks expiry.
116
+ */
117
+ export function getSessionUser(req, store, cfg) {
118
+ const token = sessionToken(req, cfg);
119
+ if (!token) return null;
120
+
121
+ const session = store.users.getSession(sha256(token));
122
+ if (!session || session.expires_at < Date.now()) return null;
123
+
124
+ return store.users.get(session.user_id);
125
+ }
126
+
127
+ /* ---------- trusted-header mode ---------- */
128
+
129
+ /** Parsed once per cidr list; `::ffff:1.2.3.4` peers are checked as IPv4. */
130
+ const blockLists = new WeakMap();
131
+ export function ipInCidrs(ip, cidrs) {
132
+ if (!ip || !Array.isArray(cidrs) || !cidrs.length) return false;
133
+ let list = blockLists.get(cidrs);
134
+ if (!list) {
135
+ list = new BlockList();
136
+ for (const cidr of cidrs) {
137
+ const [addr, bits] = String(cidr).split('/');
138
+ const family = isIP(addr) === 6 ? 'ipv6' : 'ipv4';
139
+ const prefix = bits === undefined ? (family === 'ipv6' ? 128 : 32) : Number(bits);
140
+ list.addSubnet(addr, prefix, family);
141
+ }
142
+ blockLists.set(cidrs, list);
143
+ }
144
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
145
+ if (mapped) return list.check(mapped[1], 'ipv4');
146
+ return list.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
147
+ }
148
+
149
+ const HEADER_ROLES = new Set(['admin', 'user']);
150
+ const LOGIN_TOUCH_MS = 60_000;
151
+
152
+ function header(req, name) {
153
+ const v = req.headers[String(name).toLowerCase()];
154
+ const s = Array.isArray(v) ? v[0] : v;
155
+ return typeof s === 'string' ? s.trim() : '';
156
+ }
157
+
158
+ /**
159
+ * Resolves (and on first sight provisions) the user an upstream gateway has
160
+ * vouched for. Returns { user } or { reject: reason }.
161
+ *
162
+ * Headers are believed only from a peer inside trustedProxyCidrs, judged by
163
+ * the socket address -- never X-Forwarded-For, which the client controls.
164
+ * The external id is the identity; email and name are metadata that follow
165
+ * the gateway. Role follows the gateway when it sends one; status is only
166
+ * ever set here on creation and otherwise belongs to TinyWebUI admins.
167
+ */
168
+ export function resolveTrustedUser(req, store, cfg) {
169
+ if (!ipInCidrs(req.socket?.remoteAddress, cfg.trustedProxyCidrs)) return { reject: 'untrusted_peer' };
170
+ const externalId = header(req, cfg.trustedUserIdHeader).slice(0, 256);
171
+ if (!externalId) return { reject: 'missing_identity' };
172
+ const email = header(req, cfg.trustedEmailHeader).slice(0, 320).toLowerCase() || null;
173
+ const name = header(req, cfg.trustedNameHeader).slice(0, 200) || null;
174
+ const rawRole = header(req, cfg.trustedRoleHeader).toLowerCase();
175
+ const role = HEADER_ROLES.has(rawRole) ? rawRole : null;
176
+
177
+ const now = Date.now();
178
+ // Precedence: the files (bootstrapAdmins, access.users) > gateway role
179
+ // header > defaults. What an admin decided is in access.users, so the file
180
+ // outranks a gateway that still says otherwise.
181
+ const policy = resolveAccess(cfg);
182
+ const pinned = policy.bootstrapAdmins.includes(externalId)
183
+ ? { role: 'admin', status: 'approved' }
184
+ : policy.users[externalId] || {};
185
+ const wantRole = pinned.role || role;
186
+ const status = pinned.status || policy.newUsers;
187
+ let { user, created } = store.users.provision({ externalId, role: wantRole || 'user', status, now });
188
+ if (created) audit('user.provisioned', { userId: user.id, role: user.role, status: user.status });
189
+
190
+ const changes = {};
191
+ if (email !== user.email) {
192
+ // Emails are unique; one held by another account is dropped, never merged.
193
+ const next = email && store.users.emailTaken(email, user.id) ? null : email;
194
+ if (next !== user.email) changes.email = next;
195
+ }
196
+ if (name !== user.name) changes.name = name;
197
+ if (wantRole && wantRole !== user.role) {
198
+ changes.role = wantRole;
199
+ audit('user.role_changed', { userId: user.id, from: user.role, to: wantRole, by: pinned.role ? 'policy' : 'gateway' });
200
+ }
201
+ if (pinned.status && pinned.status !== user.status) {
202
+ changes.status = pinned.status;
203
+ audit('user.status_changed', { userId: user.id, from: user.status, to: pinned.status, by: 'policy' });
204
+ }
205
+ if (!user.last_login_at || now - user.last_login_at > LOGIN_TOUCH_MS) changes.last_login_at = now;
206
+ if (Object.keys(changes).length) user = store.users.sync(user.id, changes);
207
+ return { user };
208
+ }
209
+
210
+ /**
211
+ * Brings every provisioned user in line with the files' access.users and
212
+ * bootstrapAdmins, so a status decided in a file (a ban, say) holds even for
213
+ * someone who has not made a request since -- their automations included.
214
+ */
215
+ export function applyAccessPolicy(store, cfg) {
216
+ const policy = resolveAccess(cfg);
217
+ const pins = { ...policy.users };
218
+ for (const id of policy.bootstrapAdmins) pins[id] = { role: 'admin', status: 'approved' };
219
+ for (const [externalId, pin] of Object.entries(pins)) {
220
+ const user = store.users.byExternalId(externalId);
221
+ if (!user) continue;
222
+ const role = pin.role && pin.role !== user.role ? pin.role : null;
223
+ const status = pin.status && pin.status !== user.status ? pin.status : null;
224
+ if (!role && !status) continue;
225
+ store.users.update(user.id, { ...(role ? { role } : {}), ...(status ? { status } : {}) });
226
+ audit('user.policy_applied', { userId: user.id, ...(role ? { role } : {}), ...(status ? { status } : {}) });
227
+ }
228
+ }
@@ -0,0 +1,67 @@
1
+ import { ALL_USERS } from '../store/index.js';
2
+ import { featuresFor } from './policy.js';
3
+ import { getSessionUser, resolveTrustedUser } from './auth.js';
4
+ import { audit } from '../audit.js';
5
+ import { FEATURE } from '../../public/shared/features.js';
6
+
7
+ /** The one account behind a 'single' password. */
8
+ export const OWNER_ID = 'owner';
9
+
10
+ // Reachable without a session: the login/OAuth dance itself, plus static
11
+ // assets so the login page can load before there's anyone to authenticate.
12
+ const PRE_AUTH_PATHS = /^\/api\/auth\//;
13
+ const isPreAuthPath = (pathname) => PRE_AUTH_PATHS.test(pathname) || !pathname.startsWith('/api/');
14
+
15
+ // In trusted-header mode the gateway owns login and logout; these two are
16
+ // the only /api/auth routes that exist, and both need the resolved user.
17
+ const TRUSTED_AUTH_PATHS = new Set(['/api/auth/me', '/api/auth/logout']);
18
+ // Still answered for a pending/disabled user, so the UI can say why.
19
+ const STATUS_EXEMPT_PATHS = TRUSTED_AUTH_PATHS;
20
+
21
+ function resolveIdentity(req, pathname, cfg, store) {
22
+ if (cfg.authMode === 'none') return { userId: ALL_USERS, role: null, user: null, isAdmin: true };
23
+ let user;
24
+ if (cfg.authMode === 'trusted-header') {
25
+ if (!pathname.startsWith('/api/')) return { userId: undefined, user: null, isAdmin: false };
26
+ if (pathname.startsWith('/api/auth/') && !TRUSTED_AUTH_PATHS.has(pathname)) return { notFound: true };
27
+ const result = resolveTrustedUser(req, store, cfg);
28
+ if (result.reject) {
29
+ audit('auth.rejected', { reason: result.reject, path: pathname });
30
+ return { unauthorized: true };
31
+ }
32
+ user = result.user;
33
+ } else {
34
+ user = getSessionUser(req, store, cfg);
35
+ if (!user) {
36
+ if (!isPreAuthPath(pathname)) return { unauthorized: true };
37
+ return { userId: undefined, user: null, isAdmin: false };
38
+ }
39
+ }
40
+ if (!STATUS_EXEMPT_PATHS.has(pathname) && !(cfg.authMode !== 'trusted-header' && isPreAuthPath(pathname))) {
41
+ if (user.status === 'disabled') return { disabled: true };
42
+ if (user.status !== 'approved') return { pending: true };
43
+ }
44
+ // 'single': sessions hang off OWNER_ID but data stays unowned (ALL_USERS),
45
+ // so switching between 'none' and 'single' never hides a chat.
46
+ if (cfg.authMode === 'single') return { userId: ALL_USERS, role: 'admin', user, isAdmin: true };
47
+ return { userId: user.id, role: user.role, user, isAdmin: user.role === 'admin' && user.status === 'approved' };
48
+ }
49
+
50
+ /**
51
+ * The one auth gate for the whole handler. Fails closed: with auth on, a
52
+ * request either resolves to a real user or carries `userId: undefined`,
53
+ * which every scoped store method refuses. 'none' is a single-person
54
+ * install and sees everything, exactly as before auth existed. A refusal
55
+ * comes back as one of notFound / unauthorized / disabled / pending.
56
+ */
57
+ export function resolveAuth(req, pathname, cfg, store) {
58
+ const auth = resolveIdentity(req, pathname, cfg, store);
59
+ // Features come from the policy for the caller's role; nobody signed in
60
+ // gets none, which leaves only the routes the table marks open.
61
+ auth.features = auth.userId !== undefined ? featuresFor(cfg, auth.role) : new Set();
62
+ if (cfg.authMode === 'trusted-header' && auth.user) auth.isAdmin = auth.features.has(FEATURE.ADMIN);
63
+ return auth;
64
+ }
65
+
66
+ /** Who to name in the audit log for a change: the user, or 'local' (tiers 1-2). */
67
+ export const actor = (auth) => (auth.user?.id && auth.user.id !== OWNER_ID ? auth.user.id : 'local');
@@ -0,0 +1,180 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { FEATURE } from '../../public/shared/features.js';
3
+
4
+ /**
5
+ * Access policy: who can use which parts of TinyWebUI, declared in files.
6
+ *
7
+ * The `access` block lives in tinywebui.config.js and/or config.json:
8
+ *
9
+ * access: {
10
+ * bootstrapAdmins: ['<gateway user id>'], // always admin, always approved
11
+ * newUsers: 'approved' | 'pending',
12
+ * roles: { user: { features: [...], models: [...] | '*' }, admin: { features: '*' } },
13
+ * users: { '<gateway user id>': { role?, status? } } // admin decisions, written back
14
+ * }
15
+ *
16
+ * Everything here is pure: no I/O, so the server, the reload path and the CLI
17
+ * all validate and resolve the same way.
18
+ */
19
+
20
+ /** Every feature a role can be granted. Each maps to UI and API routes. */
21
+ export const FEATURES = Object.values(FEATURE);
22
+
23
+ export const ROLES = ['admin', 'user'];
24
+ export const STATUSES = ['pending', 'approved', 'disabled'];
25
+
26
+ // Tier 3's out-of-the-box split: users work, admins run the place.
27
+ const DEFAULT_ROLES = {
28
+ user: { features: [FEATURE.CHAT, FEATURE.ATTACHMENTS, FEATURE.IMAGES, FEATURE.SEARCH, FEATURE.FOLDERS, FEATURE.AUTOMATIONS, FEATURE.STATISTICS, FEATURE.MODEL_PICKER], models: '*' },
29
+ admin: { features: '*', models: '*' }
30
+ };
31
+
32
+ /**
33
+ * Keys that can never be written through the UI or API, whatever the lock
34
+ * state: the trust boundary, credentials, and the policy itself. They can
35
+ * still be set in either file (so `set-password` can write config.json).
36
+ */
37
+ export const FILE_ONLY = new Set([
38
+ 'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
39
+ 'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
40
+ 'logoutUrl', 'baseUrl', 'apiKey', 'dbPath', 'access', 'models', 'allowedOrigins', 'frozen', 'autoMigrate', 'retrieval',
41
+ 'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
42
+ ]);
43
+
44
+ // Never logged, never fingerprinted as values, never sent to a browser.
45
+ export const SECRET_KEYS = new Set(['apiKey', 'authPassword', 'sessionSecret', 'googleClientSecret']);
46
+
47
+ /**
48
+ * Merges the file's and the code's `access` blocks. Code wins per field;
49
+ * `users` merges per user, code winning, so admin write-back to the file can
50
+ * never override a user pinned in code.
51
+ */
52
+ export function mergeAccess(fileAccess = {}, codeAccess = {}) {
53
+ const f = fileAccess && typeof fileAccess === 'object' ? fileAccess : {};
54
+ const c = codeAccess && typeof codeAccess === 'object' ? codeAccess : {};
55
+ return {
56
+ ...f, ...c,
57
+ users: { ...(f.users || {}), ...(c.users || {}) }
58
+ };
59
+ }
60
+
61
+ /** The effective policy, with defaults filled in, for a loaded config. */
62
+ export function resolveAccess(cfg) {
63
+ const a = cfg.access || {};
64
+ const roles = {};
65
+ for (const r of ROLES) roles[r] = { ...DEFAULT_ROLES[r], ...(a.roles?.[r] || {}) };
66
+ return {
67
+ bootstrapAdmins: Array.isArray(a.bootstrapAdmins) ? a.bootstrapAdmins.map(String) : [],
68
+ newUsers: a.newUsers || 'approved',
69
+ roles,
70
+ users: a.users && typeof a.users === 'object' ? a.users : {}
71
+ };
72
+ }
73
+
74
+ const expand = (list) => new Set(list === '*' ? FEATURES : list || []);
75
+
76
+ /**
77
+ * What a signed-in person may use. Tiers 1 and 2 are one person, who gets
78
+ * everything that makes sense for one person: no user management.
79
+ */
80
+ export function featuresFor(cfg, role) {
81
+ if (cfg.authMode !== 'trusted-header') {
82
+ const all = new Set(FEATURES);
83
+ all.delete('admin'); all.delete('oversight');
84
+ return all;
85
+ }
86
+ return expand(resolveAccess(cfg).roles[role]?.features);
87
+ }
88
+
89
+ /** '*' or the list of model ids this role may pick. */
90
+ export function modelsFor(cfg, role) {
91
+ if (cfg.authMode !== 'trusted-header') return '*';
92
+ const m = resolveAccess(cfg).roles[role]?.models;
93
+ return m === '*' || m === undefined ? '*' : m.map(String);
94
+ }
95
+
96
+ /** Every problem with a config, as readable strings. Empty means valid. */
97
+ export function validateConfig(cfg, { env = process.env } = {}) {
98
+ const errors = [];
99
+ const mode = cfg.authMode;
100
+ if (mode === 'multiuser') {
101
+ errors.push("authMode 'multiuser' has no login flow yet; use 'trusted-header' behind a gateway for Google/Apple/SSO sign-in");
102
+ }
103
+ if (mode === 'trusted-header' && !(Array.isArray(cfg.trustedProxyCidrs) && cfg.trustedProxyCidrs.length)) {
104
+ errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
105
+ }
106
+ if (mode === 'single') {
107
+ if (cfg.authPassword && !String(cfg.authPassword).startsWith('scrypt$')) {
108
+ errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
109
+ } else if (!cfg.authPassword && !env.TINYWEBUI_PASSWORD) {
110
+ errors.push("authMode 'single' needs a password: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
111
+ }
112
+ }
113
+
114
+ const a = cfg.access;
115
+ if (a !== undefined) {
116
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return [...errors, 'access must be an object'];
117
+ const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users']);
118
+ for (const k of Object.keys(a)) if (!known.has(k)) errors.push(`access.${k} is not a known setting`);
119
+ if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
120
+ errors.push('access.bootstrapAdmins must be a list of gateway user ids');
121
+ }
122
+ if (a.newUsers !== undefined && !['approved', 'pending'].includes(a.newUsers)) {
123
+ errors.push("access.newUsers must be 'approved' or 'pending'");
124
+ }
125
+ for (const [role, spec] of Object.entries(a.roles || {})) {
126
+ if (!ROLES.includes(role)) { errors.push(`access.roles.${role}: unknown role (known: ${ROLES.join(', ')})`); continue; }
127
+ if (!spec || typeof spec !== 'object') { errors.push(`access.roles.${role} must be an object`); continue; }
128
+ for (const k of Object.keys(spec)) {
129
+ if (!['features', 'models'].includes(k)) errors.push(`access.roles.${role}.${k} is not a known setting`);
130
+ }
131
+ if (spec.features !== undefined && spec.features !== '*') {
132
+ if (!Array.isArray(spec.features)) errors.push(`access.roles.${role}.features must be '*' or a list`);
133
+ else for (const f of spec.features) {
134
+ if (!FEATURES.includes(f)) errors.push(`access.roles.${role}.features: unknown feature "${f}" (known: ${FEATURES.join(', ')})`);
135
+ }
136
+ }
137
+ if (spec.models !== undefined && spec.models !== '*' && !(Array.isArray(spec.models) && spec.models.every((m) => typeof m === 'string' && m))) {
138
+ errors.push(`access.roles.${role}.models must be '*' or a list of model ids (catalog ids when models is set)`);
139
+ }
140
+ }
141
+ for (const [id, u] of Object.entries(a.users || {})) {
142
+ if (!u || typeof u !== 'object') { errors.push(`access.users["${id}"] must be an object`); continue; }
143
+ if (u.role !== undefined && !ROLES.includes(u.role)) errors.push(`access.users["${id}"].role must be one of ${ROLES.join(', ')}`);
144
+ if (u.status !== undefined && !STATUSES.includes(u.status)) errors.push(`access.users["${id}"].status must be one of ${STATUSES.join(', ')}`);
145
+ }
146
+ }
147
+ return errors;
148
+ }
149
+
150
+ /** Stable JSON: object keys sorted, so key order never changes a hash. */
151
+ function canonical(value) {
152
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
153
+ if (value && typeof value === 'object') {
154
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
155
+ }
156
+ return JSON.stringify(value ?? null);
157
+ }
158
+
159
+ /**
160
+ * A short hash of the effective config and MCP servers. Secrets count only
161
+ * as present/absent (their values never enter the hash), so a fingerprint can
162
+ * be logged and compared against a checked-in config without leaking anything.
163
+ */
164
+ export function fingerprint(cfg, mcpServers = {}) {
165
+ const redacted = {};
166
+ for (const [k, v] of Object.entries(cfg)) {
167
+ // The session secret is operational (it is generated on first start),
168
+ // not policy; leaving it out keeps the CLI and the server in agreement.
169
+ if (k === 'sessionSecret') continue;
170
+ redacted[k] = SECRET_KEYS.has(k) ? Boolean(v) : v;
171
+ }
172
+ return createHash('sha256').update(canonical({ config: redacted, mcpServers })).digest('hex').slice(0, 16);
173
+ }
174
+
175
+ /** 'file-only' | 'frozen' | 'editable' -- how a key may be changed. */
176
+ export function keyClass(key, lockedKeys) {
177
+ if (FILE_ONLY.has(key)) return 'file-only';
178
+ if (lockedKeys.has(key)) return 'frozen';
179
+ return 'editable';
180
+ }
package/src/audit.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * One JSON line per security-relevant event, on stdout for the deployment's
3
+ * log collector. Never pass content, prompts, tokens or raw header values.
4
+ */
5
+ export function audit(event, fields = {}) {
6
+ console.log(`[tinywebui:audit] ${JSON.stringify({ ts: new Date().toISOString(), event, ...fields })}`);
7
+ }