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
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Where an installed TinyWebUI (npx, or npm install -g) keeps its files when
3
+ * nothing says otherwise: one folder per user, the platform's usual place for
4
+ * application data, rather than whichever folder the command ran in. Config,
5
+ * keys, database and embedding models all sit there, beside the config, so
6
+ * one person has one of each and knows where to back it up or delete it.
7
+ * A clone and the Docker image keep using the working directory and their
8
+ * explicit paths.
9
+ */
10
+ import { homedir } from 'node:os';
11
+ import { isAbsolute, join, sep } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ /** The per-user data folder on `platform`. */
14
+ export function userDataDir(env = process.env, platform = process.platform, home = homedir()) {
15
+ if (platform === 'darwin')
16
+ return join(home, 'Library', 'Application Support', 'TinyWebUI');
17
+ if (platform === 'win32')
18
+ return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'TinyWebUI');
19
+ // XDG: a relative value is invalid and must be ignored.
20
+ const xdg = env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : join(home, '.local', 'share');
21
+ return join(xdg, 'tinywebui');
22
+ }
23
+ /** Whether `moduleUrl` runs from an installed package rather than a checkout. */
24
+ export const isInstalled = (moduleUrl) => fileURLToPath(moduleUrl).split(sep).includes('node_modules');
@@ -1,153 +1,229 @@
1
- /**
2
- * The model catalog: which models people may pick, what they are called, and
3
- * how each one is prompted and sampled. Declared in the config file:
4
- *
5
- * models: [
6
- * { id: 'fast', label: 'Quick', model: 'deepseek/deepseek-v4.1-flash',
7
- * description: 'Everyday questions', temperature: 0.3 },
8
- * { id: 'deep', label: 'Thorough', model: 'anthropic/claude-sonnet-5',
9
- * systemPrompt: '...', maxToolRounds: 20 }
10
- * ]
11
- *
12
- * An empty catalog (the default) changes nothing: any model id goes. A
13
- * non-empty one is closed: only its entries can be picked, the configured
14
- * `model` and every role's `models` list name entries by `id`, and people see
15
- * `label`, never the provider's id. `enabled: false` parks an entry: it keeps
16
- * its settings but cannot be picked or run until switched back on. Pure, like
17
- * policy.js.
18
- */
19
-
20
1
  /** Per-model settings that replace the global value for that model's turns. */
21
2
  export const MODEL_OVERRIDES = ['systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'cacheMode', 'cacheTtl', 'extraBody', 'reasoningReplay'];
22
-
23
3
  /** Values of `reasoningReplay`; see reasoningReplay in harness/provider.js. */
24
4
  export const REASONING_REPLAY = ['auto', 'omit', 'reasoning_content', 'reasoning_details'];
25
-
26
5
  /** What a model can do; the page uses these (e.g. `vision` gates image upload). */
27
6
  export const MODEL_TAGS = ['vision', 'reasoning', 'tools'];
28
-
29
7
  const ENTRY_KEYS = new Set(['id', 'model', 'label', 'description', 'enabled', 'connector', 'tags', ...MODEL_OVERRIDES]);
30
-
31
- /** Named endpoints: { id, label?, baseUrl, apiKey? }. The top-level baseUrl/apiKey stay the implicit default. */
8
+ /**
9
+ * The file keeps each model with the endpoint that serves it: top-level
10
+ * `models` run on the default baseUrl/apiKey, `connectors[i].models` on that
11
+ * connector, and every entry carries its own prompt, sampling and caching.
12
+ * At runtime the catalog is one flat list whose entries name their connector,
13
+ * so nothing past loading knows about the nesting. The older flat file shape
14
+ * (entries with `connector: <id>`) loads unchanged.
15
+ */
16
+ // Raw input: the file's contents, before validation.
17
+ export function flattenCatalog(cfg) {
18
+ if (!Array.isArray(cfg.connectors) || !Array.isArray(cfg.models ?? []))
19
+ return {};
20
+ const nested = [];
21
+ const connectors = cfg.connectors.map((c) => {
22
+ if (!c || typeof c !== 'object' || !Array.isArray(c.models))
23
+ return c;
24
+ const { models, ...rest } = c;
25
+ for (const m of models)
26
+ nested.push(m && typeof m === 'object' && !Array.isArray(m) ? { ...m, connector: c.id } : m);
27
+ return rest;
28
+ });
29
+ return { connectors, models: [...(cfg.models ?? []), ...nested] };
30
+ }
31
+ /** The flat runtime catalog back in the file's nested shape. */
32
+ export function nestCatalog(cfg) {
33
+ const out = {};
34
+ const connectors = Array.isArray(cfg.connectors) ? cfg.connectors : [];
35
+ const models = Array.isArray(cfg.models) ? cfg.models : [];
36
+ const ids = new Set(connectors.map((c) => c?.id));
37
+ // An entry naming a connector that is not listed stays flat, so validation
38
+ // still reports it instead of the entry silently moving endpoints.
39
+ if (cfg.models !== undefined)
40
+ out.models = models.filter((m) => !m?.connector || !ids.has(m.connector));
41
+ if (cfg.connectors !== undefined) {
42
+ out.connectors = connectors.map((c) => {
43
+ const mine = models.filter((m) => m?.connector && m.connector === c?.id).map(({ connector, ...m }) => m);
44
+ return mine.length ? { ...c, models: mine } : c;
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+ /**
50
+ * Whether turns on `key` have somewhere to go: an entry's connector, else the
51
+ * top-level endpoint if it has a URL. A key is optional either way: a local
52
+ * server may need none.
53
+ */
54
+ export function hasEndpoint(cfg, key) {
55
+ const e = findEntry(cfg, key ?? cfg.model);
56
+ return e?.connector && findConnector(cfg, e.connector) ? true : Boolean(cfg.baseUrl);
57
+ }
58
+ /**
59
+ * Named endpoints: { id, label?, baseUrl, apiKey? }. The top-level
60
+ * baseUrl/apiKey are the endpoint of a model that names none, for a
61
+ * deployment set up in code or the environment; unset by default.
62
+ */
32
63
  export const connectors = (cfg) => (Array.isArray(cfg.connectors) ? cfg.connectors : []);
33
64
  export const findConnector = (cfg, id) => connectors(cfg).find((c) => c.id === id) || null;
34
-
35
65
  export const catalog = (cfg) => (Array.isArray(cfg.models) ? cfg.models : []);
36
66
  // Closed whenever entries are listed, even if all are disabled: parking every
37
67
  // model must not reopen the picker to the provider's whole list.
38
68
  export const isClosed = (cfg) => catalog(cfg).length > 0;
39
69
  export const enabledEntries = (cfg) => catalog(cfg).filter((e) => e.enabled !== false);
40
-
70
+ /** The default model: `model` as set, or unset with a catalog, its first enabled entry. */
71
+ export const defaultModel = (cfg) => cfg.model || enabledEntries(cfg)[0]?.id || '';
41
72
  /** The enabled entry `key` names: by id, or by its provider model id (the default `model` may be either). */
42
73
  export function findEntry(cfg, key) {
43
- const list = enabledEntries(cfg);
44
- return list.find((e) => e.id === key) || list.find((e) => (e.model || e.id) === key) || null;
74
+ const list = enabledEntries(cfg);
75
+ return list.find((e) => e.id === key) || list.find((e) => (e.model || e.id) === key) || null;
45
76
  }
46
-
47
77
  /** What people call a model: its label in the catalog, else the id itself. */
48
78
  export function labelFor(cfg, key) {
49
- const e = findEntry(cfg, key);
50
- return e ? e.label || e.id : key;
79
+ const e = findEntry(cfg, key);
80
+ return e ? e.label || e.id : key;
51
81
  }
52
-
53
82
  /** What the picker lists for one entry. Prompts and knobs stay server-side. */
54
83
  export const publicEntry = (e) => ({ id: e.id, name: e.label || e.id, description: e.description || null, tags: e.tags || [] });
55
-
56
84
  /**
57
85
  * The config one turn runs with: the entry's overrides over the global
58
86
  * settings and its provider id as `model`. extraBody merges, so an entry can
59
87
  * add a reasoning knob without restating the gateway's routing.
60
88
  */
61
89
  export function effectiveConfig(cfg, key) {
62
- const e = findEntry(cfg, key ?? cfg.model);
63
- if (!e) return key && key !== cfg.model ? { ...cfg, model: key } : cfg;
64
- const out = { ...cfg };
65
- for (const k of MODEL_OVERRIDES) if (e[k] !== undefined) out[k] = e[k];
66
- out.extraBody = { ...(cfg.extraBody || {}), ...(e.extraBody || {}) };
67
- // `provider` merges one level deeper: a global data policy (zdr,
68
- // data_collection) must survive an entry that only pins its routing.
69
- if (cfg.extraBody?.provider || e.extraBody?.provider) {
70
- out.extraBody.provider = { ...(cfg.extraBody?.provider || {}), ...(e.extraBody?.provider || {}) };
71
- }
72
- out.model = e.model || e.id;
73
- // The entry's endpoint replaces the global one; provider.js only ever reads
74
- // cfg.baseUrl and cfg.apiKey, so nothing downstream knows about connectors.
75
- const conn = e.connector ? findConnector(cfg, e.connector) : null;
76
- if (conn) {
77
- out.baseUrl = String(conn.baseUrl).replace(/\/+$/, '');
78
- out.apiKey = conn.apiKey || '';
79
- }
80
- return out;
90
+ const e = findEntry(cfg, key ?? cfg.model);
91
+ if (!e)
92
+ return key && key !== cfg.model ? { ...cfg, model: key } : cfg;
93
+ const out = { ...cfg };
94
+ for (const k of MODEL_OVERRIDES)
95
+ if (e[k] !== undefined)
96
+ out[k] = e[k];
97
+ out.extraBody = { ...(cfg.extraBody || {}), ...(e.extraBody || {}) };
98
+ // `provider` merges one level deeper: a global data policy (zdr,
99
+ // data_collection) must survive an entry that only pins its routing.
100
+ if (cfg.extraBody?.provider || e.extraBody?.provider) {
101
+ out.extraBody.provider = { ...(cfg.extraBody?.provider || {}), ...(e.extraBody?.provider || {}) };
102
+ }
103
+ out.model = e.model || e.id;
104
+ // The entry's endpoint replaces the global one; provider.js only ever reads
105
+ // cfg.baseUrl and cfg.apiKey, so nothing downstream knows about connectors.
106
+ const conn = e.connector ? findConnector(cfg, e.connector) : null;
107
+ if (conn) {
108
+ out.baseUrl = String(conn.baseUrl).replace(/\/+$/, '');
109
+ out.apiKey = conn.apiKey || '';
110
+ }
111
+ return out;
81
112
  }
82
-
83
113
  /** Every problem with the connector list. */
84
114
  export function connectorProblems(cfg) {
85
- const list = cfg.connectors;
86
- if (list === undefined) return [];
87
- if (!Array.isArray(list)) return ['connectors must be a list of { id, baseUrl, apiKey?, label? } entries'];
88
- const out = [];
89
- const ids = new Set();
90
- list.forEach((c, i) => {
91
- const at = `connectors[${i}]`;
92
- if (!c || typeof c !== 'object' || Array.isArray(c)) { out.push(`${at} must be an object`); return; }
93
- if (typeof c.id !== 'string' || !c.id.trim()) { out.push(`${at}.id must be a non-empty string`); return; }
94
- if (ids.has(c.id)) out.push(`${at}.id "${c.id}" is listed twice`);
95
- ids.add(c.id);
96
- for (const k of Object.keys(c)) if (!['id', 'label', 'baseUrl', 'apiKey'].includes(k)) out.push(`${at}.${k} is not a known setting (known: id, label, baseUrl, apiKey)`);
97
- for (const k of ['label', 'apiKey']) if (c[k] !== undefined && typeof c[k] !== 'string') out.push(`${at}.${k} must be a string`);
98
- try {
99
- if (!/^https?:$/.test(new URL(c.baseUrl).protocol)) throw new Error('scheme');
100
- } catch { out.push(`${at}.baseUrl must be an http(s) URL`); }
101
- });
102
- for (const [i, e] of (Array.isArray(cfg.models) ? cfg.models : []).entries()) {
103
- if (e?.connector !== undefined && !ids.has(e.connector)) out.push(`models[${i}].connector "${e.connector}" is not in connectors`);
104
- }
105
- return out;
115
+ const list = cfg.connectors;
116
+ if (list === undefined)
117
+ return [];
118
+ if (!Array.isArray(list))
119
+ return ['connectors must be a list of { id, baseUrl, apiKey?, label?, models? } entries'];
120
+ const out = [];
121
+ const ids = new Set();
122
+ list.forEach((c, i) => {
123
+ const at = `connectors[${i}]`;
124
+ if (!c || typeof c !== 'object' || Array.isArray(c)) {
125
+ out.push(`${at} must be an object`);
126
+ return;
127
+ }
128
+ if (typeof c.id !== 'string' || !c.id.trim()) {
129
+ out.push(`${at}.id must be a non-empty string`);
130
+ return;
131
+ }
132
+ if (ids.has(c.id))
133
+ out.push(`${at}.id "${c.id}" is listed twice`);
134
+ ids.add(c.id);
135
+ // A nested `models` that is a list was already lifted out by flattenCatalog.
136
+ if (c.models !== undefined)
137
+ out.push(`${at}.models must be a list of model entries`);
138
+ for (const k of Object.keys(c))
139
+ if (!['id', 'label', 'baseUrl', 'apiKey', 'models'].includes(k))
140
+ out.push(`${at}.${k} is not a known setting (known: id, label, baseUrl, apiKey, models)`);
141
+ for (const k of ['label', 'apiKey'])
142
+ if (c[k] !== undefined && typeof c[k] !== 'string')
143
+ out.push(`${at}.${k} must be a string`);
144
+ try {
145
+ if (!/^https?:$/.test(new URL(c.baseUrl).protocol))
146
+ throw new Error('scheme');
147
+ }
148
+ catch {
149
+ out.push(`${at}.baseUrl must be an http(s) URL`);
150
+ }
151
+ });
152
+ for (const [i, e] of (Array.isArray(cfg.models) ? cfg.models : []).entries()) {
153
+ if (e?.connector !== undefined && !ids.has(e.connector))
154
+ out.push(`models[${i}].connector "${e.connector}" is not in connectors`);
155
+ }
156
+ return out;
106
157
  }
107
-
108
158
  const NUMERIC = { temperature: 'a number or null', maxTokens: 'a number or null' };
109
-
110
159
  /** Every problem with the catalog and what refers into it. */
111
160
  export function modelProblems(cfg) {
112
- const list = cfg.models;
113
- if (list === undefined) return [];
114
- if (!Array.isArray(list)) return ['models must be a list of { id, model?, label?, ... } entries'];
115
- const out = [];
116
- const ids = new Set();
117
- list.forEach((e, i) => {
118
- const at = `models[${i}]`;
119
- if (!e || typeof e !== 'object' || Array.isArray(e)) { out.push(`${at} must be an object`); return; }
120
- if (typeof e.id !== 'string' || !e.id.trim()) { out.push(`${at}.id must be a non-empty string`); return; }
121
- if (ids.has(e.id)) out.push(`${at}.id "${e.id}" is listed twice`);
122
- ids.add(e.id);
123
- for (const k of Object.keys(e)) if (!ENTRY_KEYS.has(k)) out.push(`${at}.${k} is not a known setting (known: ${[...ENTRY_KEYS].join(', ')})`);
124
- for (const k of ['model', 'label', 'description', 'systemPrompt']) {
125
- if (e[k] !== undefined && typeof e[k] !== 'string') out.push(`${at}.${k} must be a string`);
161
+ const list = cfg.models;
162
+ if (list === undefined)
163
+ return [];
164
+ if (!Array.isArray(list))
165
+ return ['models must be a list of { id, model?, label?, ... } entries'];
166
+ const out = [];
167
+ const ids = new Set();
168
+ list.forEach((e, i) => {
169
+ const at = `models[${i}]`;
170
+ if (!e || typeof e !== 'object' || Array.isArray(e)) {
171
+ out.push(`${at} must be an object`);
172
+ return;
173
+ }
174
+ if (typeof e.id !== 'string' || !e.id.trim()) {
175
+ out.push(`${at}.id must be a non-empty string`);
176
+ return;
177
+ }
178
+ if (ids.has(e.id))
179
+ out.push(`${at}.id "${e.id}" is listed twice`);
180
+ ids.add(e.id);
181
+ for (const k of Object.keys(e))
182
+ if (!ENTRY_KEYS.has(k))
183
+ out.push(`${at}.${k} is not a known setting (known: ${[...ENTRY_KEYS].join(', ')})`);
184
+ for (const k of ['model', 'label', 'description', 'systemPrompt']) {
185
+ if (e[k] !== undefined && typeof e[k] !== 'string')
186
+ out.push(`${at}.${k} must be a string`);
187
+ }
188
+ for (const k of Object.keys(NUMERIC)) {
189
+ if (e[k] !== undefined && e[k] !== null && !(typeof e[k] === 'number' && Number.isFinite(e[k])))
190
+ out.push(`${at}.${k} must be ${NUMERIC[k]}`);
191
+ }
192
+ if (e.maxToolRounds !== undefined && !(Number.isInteger(e.maxToolRounds) && e.maxToolRounds >= 0))
193
+ out.push(`${at}.maxToolRounds must be a whole number >= 0`);
194
+ if (e.cacheMode !== undefined && !['auto', 'implicit', 'explicit', 'rolling', 'off'].includes(e.cacheMode))
195
+ out.push(`${at}.cacheMode must be one of auto, implicit, explicit, rolling, off`);
196
+ if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay))
197
+ out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
198
+ if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl))
199
+ out.push(`${at}.cacheTtl must be 5m or 1h`);
200
+ if (e.enabled !== undefined && typeof e.enabled !== 'boolean')
201
+ out.push(`${at}.enabled must be true or false`);
202
+ if (e.connector !== undefined && typeof e.connector !== 'string')
203
+ out.push(`${at}.connector must be a connector id`);
204
+ if (e.tags !== undefined && !(Array.isArray(e.tags) && e.tags.every((t) => MODEL_TAGS.includes(t))))
205
+ out.push(`${at}.tags must be a list from ${MODEL_TAGS.join(', ')}`);
206
+ if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody)))
207
+ out.push(`${at}.extraBody must be an object`);
208
+ });
209
+ if (!list.length || out.length)
210
+ return out;
211
+ // A closed catalog: everything that names a model has to name an entry.
212
+ // Role lists may name disabled entries (they just drop out); the default may not.
213
+ // An unset `model` is the first enabled entry (see defaultModel).
214
+ const on = enabledEntries(cfg).map((e) => e.id);
215
+ if (!on.length)
216
+ out.push('every entry in models is disabled: enable at least one');
217
+ else if (cfg.model && !findEntry(cfg, cfg.model)) {
218
+ const off = list.some((e) => e.id === cfg.model || e.model === cfg.model);
219
+ out.push(`model "${cfg.model}" ${off ? 'is disabled' : 'is not in the models catalog'}; enabled ids: ${on.join(', ')}`);
126
220
  }
127
- for (const k of Object.keys(NUMERIC)) {
128
- if (e[k] !== undefined && e[k] !== null && !(typeof e[k] === 'number' && Number.isFinite(e[k]))) out.push(`${at}.${k} must be ${NUMERIC[k]}`);
221
+ for (const [role, spec] of Object.entries(cfg.access?.roles || {})) {
222
+ if (!Array.isArray(spec?.models))
223
+ continue;
224
+ for (const m of spec.models)
225
+ if (!ids.has(m))
226
+ out.push(`access.roles.${role}.models: "${m}" is not a models catalog id`);
129
227
  }
130
- if (e.maxToolRounds !== undefined && !(Number.isInteger(e.maxToolRounds) && e.maxToolRounds >= 0)) out.push(`${at}.maxToolRounds must be a whole number >= 0`);
131
- if (e.cacheMode !== undefined && !['auto', 'implicit', 'explicit', 'rolling', 'off'].includes(e.cacheMode)) out.push(`${at}.cacheMode must be one of auto, implicit, explicit, rolling, off`);
132
- if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay)) out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
133
- if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl)) out.push(`${at}.cacheTtl must be 5m or 1h`);
134
- if (e.enabled !== undefined && typeof e.enabled !== 'boolean') out.push(`${at}.enabled must be true or false`);
135
- if (e.connector !== undefined && typeof e.connector !== 'string') out.push(`${at}.connector must be a connector id`);
136
- if (e.tags !== undefined && !(Array.isArray(e.tags) && e.tags.every((t) => MODEL_TAGS.includes(t)))) out.push(`${at}.tags must be a list from ${MODEL_TAGS.join(', ')}`);
137
- if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody))) out.push(`${at}.extraBody must be an object`);
138
- });
139
- if (!list.length || out.length) return out;
140
- // A closed catalog: everything that names a model has to name an entry.
141
- // Role lists may name disabled entries (they just drop out); the default may not.
142
- const on = enabledEntries(cfg).map((e) => e.id);
143
- if (!on.length) out.push('every entry in models is disabled: enable at least one');
144
- else if (!findEntry(cfg, cfg.model)) {
145
- const off = list.some((e) => e.id === cfg.model || e.model === cfg.model);
146
- out.push(`model "${cfg.model}" ${off ? 'is disabled' : 'is not in the models catalog'}; enabled ids: ${on.join(', ')}`);
147
- }
148
- for (const [role, spec] of Object.entries(cfg.access?.roles || {})) {
149
- if (!Array.isArray(spec?.models)) continue;
150
- for (const m of spec.models) if (!ids.has(m)) out.push(`access.roles.${role}.models: "${m}" is not a models catalog id`);
151
- }
152
- return out;
228
+ return out;
153
229
  }
@@ -0,0 +1,32 @@
1
+ /*
2
+ * `${NAME}` references to the environment, so a file can say where a secret
3
+ * comes from without holding it: mcp.json headers and env, and the keys in
4
+ * tinywebui.config.json.
5
+ */
6
+ const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
7
+ /** True when a value names at least one environment variable. */
8
+ export const hasRef = (value) => typeof value === 'string' && /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(value);
9
+ /** The names a value refers to that the environment does not set. */
10
+ export function missingRefs(value, env = process.env) {
11
+ if (typeof value !== 'string')
12
+ return [];
13
+ return [...value.matchAll(REF)].map((m) => m[1]).filter((name) => env[name] === undefined);
14
+ }
15
+ /**
16
+ * Fills `${NAME}` from the environment. A name that is not set is left as
17
+ * written, so validation can say which one; it never becomes an empty
18
+ * string, which reads as "unauthorised" and sends people looking in the
19
+ * wrong place.
20
+ */
21
+ export function fillRefs(value, env = process.env) {
22
+ if (typeof value !== 'string')
23
+ return value;
24
+ return value.replace(REF, (ref, name) => env[name] ?? ref);
25
+ }
26
+ /** fillRefs, but a name that is not set is an error naming it and `where` it was written. */
27
+ export function expandRefs(value, env = process.env, where = 'mcp.json') {
28
+ const [missing] = missingRefs(value, env);
29
+ if (missing)
30
+ throw new Error(`${where} refers to \${${missing}}, which is not set in TinyWebUI's environment`);
31
+ return fillRefs(value, env);
32
+ }
@@ -0,0 +1,153 @@
1
+ /*
2
+ * Secrets kept out of tinywebui.config.json. The config file says what the
3
+ * deployment is -- endpoints, connectors, models, policy -- and is safe to
4
+ * read, diff and share. A key is not part of that shape, so it lives in one
5
+ * of two places instead:
6
+ *
7
+ * "${NAME}" in the config filled in from the environment (see refs.ts)
8
+ * tinywebui.secrets.json beside the config, owner-only: what the settings
9
+ * page saves, and the generated session secret
10
+ *
11
+ * A literal key still found in the config file is honoured, and moved into
12
+ * the secrets file the next time the server starts. Pure functions only; the
13
+ * files are read and written by createConfigSource().
14
+ */
15
+ import { SECRET_KEYS } from '../access/policy.js';
16
+ import { fillRefs, hasRef, missingRefs } from './refs.js';
17
+ const TOP = [...SECRET_KEYS];
18
+ /** A value that is a secret itself, rather than empty or a pointer to one. */
19
+ const literal = (v) => typeof v === 'string' && v !== '' && !hasRef(v);
20
+ /** The literal secrets a file-shaped config holds, and the config without them. */
21
+ export function extractSecrets(file) {
22
+ const shape = { ...file };
23
+ const found = {};
24
+ const names = [];
25
+ for (const k of TOP) {
26
+ if (!literal(shape[k]))
27
+ continue;
28
+ found[k] = shape[k];
29
+ names.push(k);
30
+ delete shape[k];
31
+ }
32
+ if (Array.isArray(shape.connectors)) {
33
+ shape.connectors = shape.connectors.map((c) => {
34
+ if (!c || typeof c !== 'object' || !literal(c.apiKey))
35
+ return c;
36
+ (found.connectors ??= {})[c.id] = { apiKey: c.apiKey };
37
+ names.push(`connectors.${c.id}.apiKey`);
38
+ const { apiKey, ...rest } = c;
39
+ return rest;
40
+ });
41
+ }
42
+ return { shape, found, names };
43
+ }
44
+ /** Folds newly found secrets into the stored ones; the found ones win. */
45
+ export function mergeSecrets(stored, found) {
46
+ const out = { ...stored, ...found, connectors: { ...(stored.connectors || {}), ...(found.connectors || {}) } };
47
+ if (!Object.keys(out.connectors).length)
48
+ delete out.connectors;
49
+ return out;
50
+ }
51
+ /**
52
+ * A file-shaped config with its secrets filled in: a reference from the
53
+ * environment, a literal as written, anything absent from the secrets file.
54
+ */
55
+ export function applySecrets(file, secrets, env = process.env) {
56
+ const out = { ...file };
57
+ for (const k of TOP) {
58
+ if (hasRef(out[k]))
59
+ out[k] = fillRefs(out[k], env);
60
+ else if (!literal(out[k]) && secrets[k])
61
+ out[k] = secrets[k];
62
+ }
63
+ if (Array.isArray(out.connectors)) {
64
+ out.connectors = out.connectors.map((c) => {
65
+ if (!c || typeof c !== 'object')
66
+ return c;
67
+ if (hasRef(c.apiKey))
68
+ return { ...c, apiKey: fillRefs(c.apiKey, env) };
69
+ const stored = secrets.connectors?.[c.id]?.apiKey;
70
+ return !literal(c.apiKey) && stored ? { ...c, apiKey: stored } : c;
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+ /** References in the config file the environment does not satisfy, as problems. */
76
+ export function refProblems(file, env = process.env) {
77
+ const out = [];
78
+ const check = (where, v) => {
79
+ for (const name of missingRefs(v, env))
80
+ out.push(`${where} refers to \${${name}}, which is not set in TinyWebUI's environment`);
81
+ };
82
+ for (const k of TOP)
83
+ check(k, file[k]);
84
+ for (const c of Array.isArray(file.connectors) ? file.connectors : []) {
85
+ if (c && typeof c === 'object')
86
+ check(`connectors.${c.id}.apiKey`, c.apiKey);
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * Splits a settings save. Keys typed into the page go to the secrets file and
92
+ * leave the patch (an `apiKey: undefined` left in it drops any literal from
93
+ * the config file); a "${NAME}" typed there is shape and stays in it. Sent
94
+ * empty or left out, a key is unchanged; null clears it. A key the config
95
+ * file takes from the environment cannot be overwritten from the page:
96
+ * `refused` names those.
97
+ *
98
+ * `current` is the config file as it stands (catalog flattened).
99
+ */
100
+ export function takeSecrets(patch, current, stored) {
101
+ const out = { ...patch };
102
+ const secrets = { ...stored, connectors: { ...(stored.connectors || {}) } };
103
+ const refused = [];
104
+ if ('apiKey' in out) {
105
+ const v = out.apiKey;
106
+ delete out.apiKey;
107
+ if (v === undefined || v === '') { /* unchanged */ }
108
+ else if (hasRef(current.apiKey) && !hasRef(v))
109
+ refused.push(`apiKey (comes from ${current.apiKey})`);
110
+ else if (v === null) {
111
+ delete secrets.apiKey;
112
+ out.apiKey = undefined;
113
+ }
114
+ else if (hasRef(v)) {
115
+ delete secrets.apiKey;
116
+ out.apiKey = v;
117
+ }
118
+ else {
119
+ secrets.apiKey = String(v);
120
+ out.apiKey = undefined;
121
+ }
122
+ }
123
+ if (Array.isArray(out.connectors)) {
124
+ const old = new Map((Array.isArray(current.connectors) ? current.connectors : []).map((c) => [c?.id, c]));
125
+ const kept = {};
126
+ out.connectors = out.connectors.map((c) => {
127
+ if (!c || typeof c !== 'object')
128
+ return c;
129
+ const { apiKey, ...rest } = c;
130
+ const prev = old.get(c.id);
131
+ if (hasRef(prev?.apiKey) && !hasRef(apiKey)) {
132
+ if (apiKey !== undefined && apiKey !== '' && apiKey !== null)
133
+ refused.push(`connectors.${c.id}.apiKey (comes from ${prev.apiKey})`);
134
+ return { ...rest, apiKey: prev.apiKey };
135
+ }
136
+ if (hasRef(apiKey))
137
+ return { ...rest, apiKey };
138
+ if (apiKey === null)
139
+ return rest;
140
+ const key = apiKey === undefined || apiKey === ''
141
+ ? secrets.connectors[c.id]?.apiKey ?? (literal(prev?.apiKey) ? prev.apiKey : undefined)
142
+ : String(apiKey);
143
+ if (key)
144
+ kept[c.id] = { apiKey: key };
145
+ return rest;
146
+ });
147
+ // A connector that is gone takes its key with it.
148
+ secrets.connectors = kept;
149
+ }
150
+ if (!Object.keys(secrets.connectors).length)
151
+ delete secrets.connectors;
152
+ return { patch: out, secrets, refused };
153
+ }