tinywebui 0.1.4 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,149 +1,189 @@
1
- import { json, readJson } from '../http.js';
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { json, readJson, oneOf } from '../http.js';
2
3
  import { ALL_USERS, toView } from '../store/index.js';
3
- import { validateConfig, fingerprint, resolveAccess, FEATURES, CUSTOMIZABLE } from '../access/policy.js';
4
+ import { validateConfig, fingerprint, resolveAccess, FEATURES, CUSTOMIZABLE, ROLES, STATUSES } from '../access/policy.js';
4
5
  import { catalog, modelProblems } from '../config/models.js';
5
- import { destroyUserSessions } from '../access/auth.js';
6
+ import { destroyUserSessions, hashToken } from '../access/auth.js';
7
+ import { normalizeEmail, validEmail } from '../access/email.js';
6
8
  import { audit } from '../audit.js';
7
9
  import { FEATURE } from '../../public/shared/features.js';
8
-
9
10
  function adminUserView(u) {
10
- return {
11
- id: u.id, email: u.email, name: u.name ?? null, role: u.role, status: u.status,
12
- createdAt: u.created_at, lastLoginAt: u.last_login_at, chatCount: u.chat_count ?? null
13
- };
11
+ return {
12
+ id: u.id, email: u.email, name: u.name ?? null, role: u.role, status: u.status,
13
+ createdAt: u.created_at, lastLoginAt: u.last_login_at, chatCount: u.chat_count ?? null
14
+ };
14
15
  }
15
-
16
16
  /**
17
17
  * User management and oversight. They only mean something when there is more
18
- * than one person; 'none' and 'single' are just you, and get admin_only.
18
+ * than one person; 'single' is just you, and gets admin_only.
19
19
  */
20
20
  export function adminRoutes({ config, multiUser, refreshConfig, runs, source, store }) {
21
-
22
- // Wraps a handler so it refuses outside multi-user deployments.
23
- const multi = (handle) => (ctx) => (multiUser() ? handle(ctx) : json(ctx.res, 403, { error: 'admin_only' }));
24
-
25
- return [
26
- { method: 'GET', path: /^\/api\/admin\/users$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
27
- const pins = resolveAccess(config());
28
- const code = source.codeAccessUsers();
29
- const pinnedBy = (u) => (pins.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins'
30
- : code[u.external_id] ? 'access.users' : null);
31
- return json(res, 200, {
32
- fingerprint: fingerprint(config(), source.loadMcpServers()),
33
- users: store.users.list().map((u) => ({ ...adminUserView(u), pinnedInCode: pinnedBy(u) }))
34
- });
35
- }) },
36
-
37
- { method: 'PATCH', path: /^\/api\/admin\/users\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth, params: [id] }) => {
38
- const target = store.users.get(id);
39
- if (!target) return json(res, 404, { error: 'no such user' });
40
- const { role, status } = await readJson(req);
41
- if (role !== undefined && !['admin', 'user'].includes(role)) return json(res, 400, { error: 'role must be admin or user' });
42
- if (status !== undefined && !['pending', 'approved', 'disabled'].includes(status)) {
43
- return json(res, 400, { error: 'status must be pending, approved or disabled' });
44
- }
45
- if (target.id === auth.userId && ((role && role !== 'admin') || (status && status !== 'approved'))) {
46
- return json(res, 400, { error: 'you cannot demote or disable your own account' });
47
- }
48
- const pins = resolveAccess(config());
49
- if (target.external_id && pins.bootstrapAdmins.includes(target.external_id)) {
50
- return json(res, 400, { error: 'this admin is declared in code (access.bootstrapAdmins)' });
51
- }
52
- // The file is the record: the decision goes into config.json's
53
- // access.users first, and only then into the database.
54
- if (target.external_id) {
55
- const file = source.readFile();
56
- const access = file.access && typeof file.access === 'object' ? file.access : {};
57
- const users = { ...(access.users || {}) };
58
- users[target.external_id] = {
59
- ...(users[target.external_id] || {}),
60
- ...(role !== undefined ? { role } : {}),
61
- ...(status !== undefined ? { status } : {})
62
- };
63
- const merged = { ...file, access: { ...access, users } };
64
- const problems = validateConfig({ ...config(), access: merged.access });
65
- if (problems.length) return json(res, 400, { error: problems.join('; ') });
66
- if (source.codeAccessUsers()[target.external_id]) return json(res, 409, { error: 'this user is pinned in code (access.users)' });
67
- source.writeFile(merged);
68
- refreshConfig();
69
- }
70
- const updated = store.users.update(target.id, { role, status });
71
- if (role !== undefined && role !== target.role) {
72
- audit('user.role_changed', { userId: target.id, from: target.role, to: role, by: auth.userId });
73
- }
74
- if (status !== undefined && status !== target.status) {
75
- audit('user.status_changed', { userId: target.id, from: target.status, to: status, by: auth.userId });
76
- if (status !== 'approved') destroyUserSessions(store, target.id);
77
- }
78
- return json(res, 200, { user: adminUserView(updated) });
79
- }) },
80
-
81
- // What the `user` role may do, what new users start as, and what users may
82
- // personalise. The admin role is not editable here: it cannot be locked
83
- // out of the panel that edits it. Written to the file like every decision.
84
- { method: 'GET', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
85
- const a = resolveAccess(config());
86
- return json(res, 200, {
87
- features: FEATURES, customizable: CUSTOMIZABLE, models: catalog(config()).map((m) => m.id),
88
- user: a.roles.user, newUsers: a.newUsers, customize: a.customize,
89
- locked: source.lockedKeys().has('access')
90
- });
91
- }) },
92
-
93
- { method: 'POST', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
94
- if (source.isFrozen() || source.lockedKeys().has('access')) return json(res, 409, { error: 'access is set in code or frozen: change the files instead' });
95
- const { features, models, newUsers, customize } = await readJson(req);
96
- const file = source.readFile();
97
- const access = file.access && typeof file.access === 'object' ? file.access : {};
98
- const user = { ...(access.roles?.user || {}) };
99
- if (features !== undefined) user.features = features;
100
- if (models !== undefined) user.models = models;
101
- const merged = {
102
- ...access,
103
- roles: { ...(access.roles || {}), user },
104
- ...(newUsers !== undefined ? { newUsers } : {}),
105
- ...(customize !== undefined ? { customize } : {})
106
- };
107
- const next = { ...file, access: merged };
108
- const problems = [...validateConfig({ ...config(), access: merged }), ...modelProblems({ ...config(), access: merged })];
109
- if (problems.length) return json(res, 400, { error: problems.join('; ') });
110
- source.writeFile(next);
111
- refreshConfig();
112
- audit('admin.policy_changed', { by: auth.userId, user, newUsers: merged.newUsers ?? null, customize: merged.customize ?? null });
113
- const a = resolveAccess(config());
114
- return json(res, 200, { user: a.roles.user, newUsers: a.newUsers, customize: a.customize });
115
- }) },
116
-
117
- // Oversight: read-only, every view audited. Reached through ALL_USERS on
118
- // purpose -- this is the one place a user's scope is crossed, and it only
119
- // exists when there are users to cross between.
120
- { method: 'GET', path: /^\/api\/admin\/users\/([\w-]+)\/chats$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
121
- const target = store.users.get(id);
122
- if (!target) return json(res, 404, { error: 'no such user' });
123
- audit('admin.view_user_chats', { by: auth.userId, userId: target.id });
124
- const chats = store.chats.list(500, target.id).map((c) => ({ ...c, running: runs.isRunning(c.id) }));
125
- return json(res, 200, { user: adminUserView(target), chats, summary: store.usage.statistics(target.id).summary });
126
- }) },
127
-
128
- { method: 'GET', path: /^\/api\/admin\/chats\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
129
- const found = store.chats.get(id, ALL_USERS);
130
- if (!found) return json(res, 404, { error: 'no such chat' });
131
- audit('admin.view_chat', { by: auth.userId, chatId: found.id, owner: found.user_id });
132
- const owner = found.user_id ? store.users.get(found.user_id) : null;
133
- return json(res, 200, {
134
- id: found.id, title: found.title, updatedAt: found.updated_at,
135
- owner: owner ? adminUserView(owner) : null,
136
- running: runs.isRunning(found.id),
137
- messages: store.messages.list(found.id).map(toView),
138
- documents: store.documents.list(found.id)
139
- });
140
- }) },
141
-
142
- { method: 'GET', path: /^\/api\/admin\/documents\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
143
- const doc = store.documents.get(id, ALL_USERS);
144
- if (!doc) return json(res, 404, { error: 'no such document' });
145
- audit('admin.view_document', { by: auth.userId, documentId: doc.id, chatId: doc.chat_id });
146
- return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
147
- }) }
148
- ];
21
+ // Wraps a handler so it refuses outside multi-user deployments.
22
+ const multi = (handle) => (ctx) => (multiUser() ? handle(ctx) : json(ctx.res, 403, { error: 'admin_only' }));
23
+ return [
24
+ { method: 'GET', path: /^\/api\/admin\/invites$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
25
+ return json(res, 200, { invites: store.users.listInvites().map((i) => ({
26
+ id: i.id, email: i.email, createdAt: i.created_at, expiresAt: i.expires_at
27
+ })) });
28
+ }) },
29
+ { method: 'POST', path: /^\/api\/admin\/invites$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
30
+ if (config().authMode !== 'multiuser')
31
+ return json(res, 400, { error: 'local invitations are only available in multiuser mode' });
32
+ const body = await readJson(req);
33
+ const email = normalizeEmail(body.email) || null;
34
+ if (email && !validEmail(email))
35
+ return json(res, 400, { error: 'enter a valid email address' });
36
+ if (email && store.users.emailTaken(email, ''))
37
+ return json(res, 409, { error: 'that email address already belongs to a user' });
38
+ const hours = body.expiresHours === undefined ? 168 : Number(body.expiresHours);
39
+ if (!(Number.isFinite(hours) && hours >= 1 && hours <= 720))
40
+ return json(res, 400, { error: 'expiry must be between 1 and 720 hours' });
41
+ const token = randomBytes(32).toString('hex');
42
+ const invite = store.users.createInvite({
43
+ id: randomUUID(), tokenHash: hashToken(token), email, createdBy: auth.user.id,
44
+ expiresAt: Date.now() + Math.round(hours * 3600_000)
45
+ });
46
+ audit('invite.created', { inviteId: invite.id, email, by: auth.user.id, expiresAt: invite.expires_at });
47
+ return json(res, 201, {
48
+ invite: { id: invite.id, email, createdAt: invite.created_at, expiresAt: invite.expires_at },
49
+ token, path: `/?invite=${token}`
50
+ });
51
+ }) },
52
+ { method: 'DELETE', path: /^\/api\/admin\/invites\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(({ res, auth, params: [id] }) => {
53
+ if (!store.users.revokeInvite(id))
54
+ return json(res, 404, { error: 'no active invitation with that id' });
55
+ audit('invite.revoked', { inviteId: id, by: auth.user.id });
56
+ return json(res, 200, { ok: true });
57
+ }) },
58
+ { method: 'GET', path: /^\/api\/admin\/users$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
59
+ const pins = resolveAccess(config());
60
+ const code = source.codeAccessUsers();
61
+ const pinnedBy = (u) => (pins.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins'
62
+ : code[u.external_id] ? 'access.users' : null);
63
+ return json(res, 200, {
64
+ fingerprint: fingerprint(config(), source.loadMcpServers()),
65
+ users: store.users.list().map((u) => ({ ...adminUserView(u), pinnedInCode: pinnedBy(u) }))
66
+ });
67
+ }) },
68
+ { method: 'PATCH', path: /^\/api\/admin\/users\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth, params: [id] }) => {
69
+ const target = store.users.get(id);
70
+ if (!target)
71
+ return json(res, 404, { error: 'no such user' });
72
+ const { role, status } = await readJson(req);
73
+ if (role !== undefined && !oneOf(role, ROLES))
74
+ return json(res, 400, { error: 'role must be admin or user' });
75
+ if (status !== undefined && !oneOf(status, STATUSES)) {
76
+ return json(res, 400, { error: 'status must be pending, approved or disabled' });
77
+ }
78
+ if (target.id === auth.userId && ((role && role !== 'admin') || (status && status !== 'approved'))) {
79
+ return json(res, 400, { error: 'you cannot demote or disable your own account' });
80
+ }
81
+ const pins = resolveAccess(config());
82
+ if (target.external_id && pins.bootstrapAdmins.includes(target.external_id)) {
83
+ return json(res, 400, { error: 'this admin is declared in code (access.bootstrapAdmins)' });
84
+ }
85
+ // The file is the record: the decision goes into config.json's
86
+ // access.users first, and only then into the database.
87
+ if (target.external_id) {
88
+ const file = source.readFile();
89
+ const access = file.access && typeof file.access === 'object' ? file.access : {};
90
+ const users = { ...(access.users || {}) };
91
+ users[target.external_id] = {
92
+ ...(users[target.external_id] || {}),
93
+ ...(role !== undefined ? { role } : {}),
94
+ ...(status !== undefined ? { status } : {})
95
+ };
96
+ const merged = { ...file, access: { ...access, users } };
97
+ const problems = validateConfig({ ...config(), access: merged.access });
98
+ if (problems.length)
99
+ return json(res, 400, { error: problems.join('; ') });
100
+ if (source.codeAccessUsers()[target.external_id])
101
+ return json(res, 409, { error: 'this user is pinned in code (access.users)' });
102
+ source.writeFile(merged);
103
+ refreshConfig();
104
+ }
105
+ const updated = store.users.update(target.id, { role, status });
106
+ if (role !== undefined && role !== target.role) {
107
+ audit('user.role_changed', { userId: target.id, from: target.role, to: role, by: auth.userId });
108
+ }
109
+ if (status !== undefined && status !== target.status) {
110
+ audit('user.status_changed', { userId: target.id, from: target.status, to: status, by: auth.userId });
111
+ if (status !== 'approved')
112
+ destroyUserSessions(store, target.id);
113
+ }
114
+ return json(res, 200, { user: adminUserView(updated) });
115
+ }) },
116
+ // What the `user` role may do, what new users start as, and what users may
117
+ // personalise. The admin role is not editable here: it cannot be locked
118
+ // out of the panel that edits it. Written to the file like every decision.
119
+ { method: 'GET', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
120
+ const a = resolveAccess(config());
121
+ return json(res, 200, {
122
+ features: FEATURES, customizable: CUSTOMIZABLE, models: catalog(config()).map((m) => m.id),
123
+ user: a.roles.user, newUsers: a.newUsers, customize: a.customize,
124
+ locked: source.lockedKeys().has('access')
125
+ });
126
+ }) },
127
+ { method: 'POST', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
128
+ if (source.isFrozen() || source.lockedKeys().has('access'))
129
+ return json(res, 409, { error: 'access is set in code or frozen: change the files instead' });
130
+ const { features, models, newUsers, customize } = await readJson(req);
131
+ const file = source.readFile();
132
+ const access = file.access && typeof file.access === 'object' ? file.access : {};
133
+ const user = { ...(access.roles?.user || {}) };
134
+ if (features !== undefined)
135
+ user.features = features;
136
+ if (models !== undefined)
137
+ user.models = models;
138
+ const merged = {
139
+ ...access,
140
+ roles: { ...(access.roles || {}), user },
141
+ ...(newUsers !== undefined ? { newUsers } : {}),
142
+ ...(customize !== undefined ? { customize } : {})
143
+ };
144
+ const next = { ...file, access: merged };
145
+ const problems = [...validateConfig({ ...config(), access: merged }), ...modelProblems({ ...config(), access: merged })];
146
+ if (problems.length)
147
+ return json(res, 400, { error: problems.join('; ') });
148
+ source.writeFile(next);
149
+ refreshConfig();
150
+ audit('admin.policy_changed', { by: auth.userId, user, newUsers: merged.newUsers ?? null, customize: merged.customize ?? null });
151
+ const a = resolveAccess(config());
152
+ return json(res, 200, { user: a.roles.user, newUsers: a.newUsers, customize: a.customize });
153
+ }) },
154
+ // Oversight: read-only, every view audited. Reached through ALL_USERS on
155
+ // purpose -- this is the one place a user's scope is crossed, and it only
156
+ // exists when there are users to cross between.
157
+ { method: 'GET', path: /^\/api\/admin\/users\/([\w-]+)\/chats$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
158
+ const target = store.users.get(id);
159
+ if (!target)
160
+ return json(res, 404, { error: 'no such user' });
161
+ audit('admin.view_user_chats', { by: auth.userId, userId: target.id });
162
+ const chats = store.chats.list(500, target.id).map((c) => ({ ...c, running: runs.isRunning(c.id) }));
163
+ // The figures the oversight header shows.
164
+ const { rounds, pricedRounds, reportedCost } = store.usage.statistics(target.id);
165
+ return json(res, 200, { user: adminUserView(target), chats, summary: { rounds, pricedRounds, reportedCost } });
166
+ }) },
167
+ { method: 'GET', path: /^\/api\/admin\/chats\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
168
+ const found = store.chats.get(id, ALL_USERS);
169
+ if (!found)
170
+ return json(res, 404, { error: 'no such chat' });
171
+ audit('admin.view_chat', { by: auth.userId, chatId: found.id, owner: found.user_id });
172
+ const owner = found.user_id ? store.users.get(found.user_id) : null;
173
+ return json(res, 200, {
174
+ id: found.id, title: found.title, updatedAt: found.updated_at,
175
+ owner: owner ? adminUserView(owner) : null,
176
+ running: runs.isRunning(found.id),
177
+ messages: store.messages.list(found.id).map(toView),
178
+ documents: store.documents.list(found.id)
179
+ });
180
+ }) },
181
+ { method: 'GET', path: /^\/api\/admin\/documents\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
182
+ const doc = store.documents.get(id, ALL_USERS);
183
+ if (!doc)
184
+ return json(res, 404, { error: 'no such document' });
185
+ audit('admin.view_document', { by: auth.userId, documentId: doc.id, chatId: doc.chat_id });
186
+ return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
187
+ }) }
188
+ ];
149
189
  }