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.
- package/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -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
|
+
}
|