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
@@ -2,159 +2,149 @@ import { randomBytes, createHmac, createHash, timingSafeEqual, scryptSync } from
2
2
  import { BlockList, isIP } from 'node:net';
3
3
  import { resolveAccess } from './policy.js';
4
4
  import { audit } from '../audit.js';
5
-
6
5
  /**
7
- * Session/cookie plumbing for authMode 'single' (password login), plus the
8
- * identity resolution for 'trusted-header' further down.
6
+ * Session/cookie plumbing for local password login ('single' and 'multiuser'),
7
+ * plus the identity resolution for 'trusted-header' further down.
9
8
  * No framework, no dependency: a signed HTTP-only cookie names a session
10
9
  * 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
10
+ * value) against the `sessions` table. When authMode is 'local' none of this
12
11
  * is ever called -- see auth_gate.js.
13
12
  */
14
-
15
13
  const COOKIE_NAME = 'tinywebui_session';
16
-
17
14
  function sha256(value) {
18
- return createHash('sha256').update(value).digest('hex');
15
+ return createHash('sha256').update(value).digest('hex');
19
16
  }
20
-
17
+ /** Digests bearer-style secrets before persistence. */
18
+ export const hashToken = (value) => sha256(String(value));
21
19
  /** HMAC-signs a value so a client can hold it without being able to forge it. */
22
20
  function sign(value, secret) {
23
- return createHmac('sha256', secret).update(value).digest('hex');
21
+ return createHmac('sha256', secret).update(value).digest('hex');
24
22
  }
25
-
26
23
  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);
24
+ const expected = sign(value, secret);
25
+ const a = Buffer.from(sig, 'hex');
26
+ const b = Buffer.from(expected, 'hex');
27
+ return a.length === b.length && timingSafeEqual(a, b);
31
28
  }
32
-
33
29
  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;
30
+ const token = randomBytes(32).toString('hex');
31
+ store.users.addSession(sha256(token), userId, Date.now() + ttlDays * 86400_000);
32
+ return token;
37
33
  }
38
-
39
34
  export function destroySession(store, token) {
40
- store.users.deleteSession(sha256(token));
35
+ store.users.deleteSession(sha256(token));
41
36
  }
42
-
43
37
  export function destroyUserSessions(store, userId) {
44
- store.users.deleteUserSessions(userId);
38
+ store.users.deleteUserSessions(userId);
45
39
  }
46
-
47
40
  /** Builds the Set-Cookie header value for a freshly created session token. */
48
41
  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
-
42
+ const signature = sign(token, secret);
43
+ const parts = [
44
+ `${COOKIE_NAME}=${token}.${signature}`,
45
+ 'Path=/', 'HttpOnly', 'SameSite=Lax',
46
+ ];
47
+ if (secure)
48
+ parts.push('Secure');
49
+ return parts.join('; ');
50
+ }
58
51
  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('; ');
52
+ const parts = [`${COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
53
+ if (secure)
54
+ parts.push('Secure');
55
+ return parts.join('; ');
62
56
  }
63
-
64
- /* ---------- password (authMode 'single') ---------- */
65
-
57
+ /* ---------- local passwords ---------- */
66
58
  const SCRYPT = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
67
-
68
59
  /** `scrypt$<salt hex>$<hash hex>` -- what authPassword holds. Never plaintext. */
69
60
  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')}`;
61
+ const salt = randomBytes(16);
62
+ const hash = scryptSync(String(password), salt, 32, SCRYPT);
63
+ return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
73
64
  }
74
-
75
65
  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);
66
+ const [kind, saltHex, hashHex] = String(stored || '').split('$');
67
+ if (kind !== 'scrypt' || !saltHex || !hashHex)
68
+ return false;
69
+ const expected = Buffer.from(hashHex, 'hex');
70
+ const actual = scryptSync(String(password ?? ''), Buffer.from(saltHex, 'hex'), expected.length, SCRYPT);
71
+ return timingSafeEqual(actual, expected);
81
72
  }
82
-
83
73
  /** The raw session token from a validly signed cookie, or null. */
84
74
  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
-
75
+ const raw = readCookie(req);
76
+ if (!raw)
77
+ return null;
78
+ const dot = raw.lastIndexOf('.');
79
+ if (dot === -1)
80
+ return null;
81
+ const token = raw.slice(0, dot);
82
+ return cfg.sessionSecret && verify(token, raw.slice(dot + 1), cfg.sessionSecret) ? token : null;
83
+ }
93
84
  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
-
85
+ const header = req.headers.cookie;
86
+ if (!header)
87
+ return null;
88
+ for (const part of header.split(';')) {
89
+ const idx = part.indexOf('=');
90
+ if (idx === -1)
91
+ continue;
92
+ const name = part.slice(0, idx).trim();
93
+ if (name === COOKIE_NAME)
94
+ return part.slice(idx + 1).trim();
95
+ }
96
+ return null;
97
+ }
105
98
  /** Whether the connection looks TLS-terminated, for the cookie's Secure flag. */
106
99
  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);
100
+ if (req.socket?.encrypted === true)
101
+ return true;
102
+ // X-Forwarded-Proto is a claim only a known proxy may make.
103
+ return req.headers['x-forwarded-proto'] === 'https' && ipInCidrs(req.socket?.remoteAddress, trustedProxyCidrs);
110
104
  }
111
-
112
105
  /**
113
106
  * Resolves the logged-in user for a request, or null. Verifies the cookie's
114
107
  * signature first (cheap, rejects tampering/forgery), then looks up the
115
108
  * token's hash in `sessions` and checks expiry.
116
109
  */
117
110
  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
-
111
+ const token = sessionToken(req, cfg);
112
+ if (!token)
113
+ return null;
114
+ const session = store.users.getSession(sha256(token));
115
+ if (!session || session.expires_at < Date.now())
116
+ return null;
117
+ return store.users.get(session.user_id);
118
+ }
127
119
  /* ---------- trusted-header mode ---------- */
128
-
129
120
  /** Parsed once per cidr list; `::ffff:1.2.3.4` peers are checked as IPv4. */
130
121
  const blockLists = new WeakMap();
131
122
  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);
123
+ if (!ip || !Array.isArray(cidrs) || !cidrs.length)
124
+ return false;
125
+ let list = blockLists.get(cidrs);
126
+ if (!list) {
127
+ list = new BlockList();
128
+ for (const cidr of cidrs) {
129
+ const [addr, bits] = String(cidr).split('/');
130
+ const family = isIP(addr) === 6 ? 'ipv6' : 'ipv4';
131
+ const prefix = bits === undefined ? (family === 'ipv6' ? 128 : 32) : Number(bits);
132
+ list.addSubnet(addr, prefix, family);
133
+ }
134
+ blockLists.set(cidrs, list);
141
135
  }
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');
136
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
137
+ if (mapped)
138
+ return list.check(mapped[1], 'ipv4');
139
+ return list.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
147
140
  }
148
-
149
141
  const HEADER_ROLES = new Set(['admin', 'user']);
150
142
  const LOGIN_TOUCH_MS = 60_000;
151
-
152
143
  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() : '';
144
+ const v = req.headers[String(name).toLowerCase()];
145
+ const s = Array.isArray(v) ? v[0] : v;
146
+ return typeof s === 'string' ? s.trim() : '';
156
147
  }
157
-
158
148
  /**
159
149
  * Resolves (and on first sight provisions) the user an upstream gateway has
160
150
  * vouched for. Returns { user } or { reject: reason }.
@@ -166,63 +156,73 @@ function header(req, name) {
166
156
  * ever set here on creation and otherwise belongs to TinyWebUI admins.
167
157
  */
168
158
  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
-
159
+ if (!ipInCidrs(req.socket?.remoteAddress, cfg.trustedProxyCidrs))
160
+ return { reject: 'untrusted_peer' };
161
+ const externalId = header(req, cfg.trustedUserIdHeader).slice(0, 256);
162
+ if (!externalId)
163
+ return { reject: 'missing_identity' };
164
+ const email = header(req, cfg.trustedEmailHeader).slice(0, 320).toLowerCase() || null;
165
+ const name = header(req, cfg.trustedNameHeader).slice(0, 200) || null;
166
+ const rawRole = header(req, cfg.trustedRoleHeader).toLowerCase();
167
+ const role = HEADER_ROLES.has(rawRole) ? rawRole : null;
168
+ const now = Date.now();
169
+ // Precedence: the files (bootstrapAdmins, access.users) > gateway role
170
+ // header > defaults. What an admin decided is in access.users, so the file
171
+ // outranks a gateway that still says otherwise.
172
+ const policy = resolveAccess(cfg);
173
+ const pinned = policy.bootstrapAdmins.includes(externalId)
174
+ ? { role: 'admin', status: 'approved' }
175
+ : policy.users[externalId] || {};
176
+ const wantRole = pinned.role || role;
177
+ const status = pinned.status || policy.newUsers;
178
+ // provision() reads back the row it just made sure exists.
179
+ const provisioned = store.users.provision({ externalId, role: wantRole || 'user', status, now });
180
+ let user = provisioned.user;
181
+ const { created } = provisioned;
182
+ if (created)
183
+ audit('user.provisioned', { userId: user.id, role: user.role, status: user.status });
184
+ const changes = {};
185
+ if (email !== user.email) {
186
+ // Emails are unique; one held by another account is dropped, never merged.
187
+ const next = email && store.users.emailTaken(email, user.id) ? null : email;
188
+ if (next !== user.email)
189
+ changes.email = next;
190
+ }
191
+ if (name !== user.name)
192
+ changes.name = name;
193
+ if (wantRole && wantRole !== user.role) {
194
+ changes.role = wantRole;
195
+ audit('user.role_changed', { userId: user.id, from: user.role, to: wantRole, by: pinned.role ? 'policy' : 'gateway' });
196
+ }
197
+ if (pinned.status && pinned.status !== user.status) {
198
+ changes.status = pinned.status;
199
+ audit('user.status_changed', { userId: user.id, from: user.status, to: pinned.status, by: 'policy' });
200
+ }
201
+ if (!user.last_login_at || now - user.last_login_at > LOGIN_TOUCH_MS)
202
+ changes.last_login_at = now;
203
+ if (Object.keys(changes).length)
204
+ user = store.users.sync(user.id, changes);
205
+ return { user };
206
+ }
210
207
  /**
211
208
  * Brings every provisioned user in line with the files' access.users and
212
209
  * bootstrapAdmins, so a status decided in a file (a ban, say) holds even for
213
210
  * someone who has not made a request since -- their automations included.
214
211
  */
215
212
  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
- }
213
+ const policy = resolveAccess(cfg);
214
+ const pins = { ...policy.users };
215
+ for (const id of policy.bootstrapAdmins)
216
+ pins[id] = { role: 'admin', status: 'approved' };
217
+ for (const [externalId, pin] of Object.entries(pins)) {
218
+ const user = store.users.byExternalId(externalId);
219
+ if (!user)
220
+ continue;
221
+ const role = pin.role && pin.role !== user.role ? pin.role : null;
222
+ const status = pin.status && pin.status !== user.status ? pin.status : null;
223
+ if (!role && !status)
224
+ 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
228
  }
@@ -3,65 +3,66 @@ import { featuresFor } from './policy.js';
3
3
  import { getSessionUser, resolveTrustedUser } from './auth.js';
4
4
  import { audit } from '../audit.js';
5
5
  import { FEATURE } from '../../public/shared/features.js';
6
-
7
- /** The one account behind a 'single' password. */
6
+ /** Historical owner row removed when an old installation claims its first account. */
8
7
  export const OWNER_ID = 'owner';
9
-
8
+ /** Holding any feature implies this (resolveAuth grants none without a user); the check makes it explicit. */
9
+ export const isSignedIn = (auth) => auth.userId !== undefined;
10
10
  // Reachable without a session: the login/OAuth dance itself, plus static
11
11
  // assets so the login page can load before there's anyone to authenticate.
12
12
  const PRE_AUTH_PATHS = /^\/api\/auth\//;
13
13
  const isPreAuthPath = (pathname) => PRE_AUTH_PATHS.test(pathname) || !pathname.startsWith('/api/');
14
-
15
14
  // In trusted-header mode the gateway owns login and logout; these two are
16
15
  // the only /api/auth routes that exist, and both need the resolved user.
17
16
  const TRUSTED_AUTH_PATHS = new Set(['/api/auth/me', '/api/auth/logout']);
18
17
  // Still answered for a pending/disabled user, so the UI can say why.
19
18
  const STATUS_EXEMPT_PATHS = TRUSTED_AUTH_PATHS;
20
-
21
19
  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 };
20
+ if (cfg.authMode === 'local')
21
+ return { userId: ALL_USERS, role: null, user: null, isAdmin: true };
22
+ let user;
23
+ if (cfg.authMode === 'trusted-header') {
24
+ if (!pathname.startsWith('/api/'))
25
+ return { userId: undefined, user: null, isAdmin: false };
26
+ if (pathname.startsWith('/api/auth/') && !TRUSTED_AUTH_PATHS.has(pathname))
27
+ return { notFound: true };
28
+ const result = resolveTrustedUser(req, store, cfg);
29
+ if (result.reject) {
30
+ audit('auth.rejected', { reason: result.reject, path: pathname });
31
+ return { unauthorized: true };
32
+ }
33
+ user = result.user;
31
34
  }
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 };
35
+ else {
36
+ user = getSessionUser(req, store, cfg);
37
+ if (!user) {
38
+ if (!isPreAuthPath(pathname))
39
+ return { unauthorized: true };
40
+ return { userId: undefined, user: null, isAdmin: false };
41
+ }
38
42
  }
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' };
43
+ if (!STATUS_EXEMPT_PATHS.has(pathname) && !(cfg.authMode !== 'trusted-header' && isPreAuthPath(pathname))) {
44
+ if (user.status === 'disabled')
45
+ return { disabled: true };
46
+ if (user.status !== 'approved')
47
+ return { pending: true };
48
+ }
49
+ return { userId: user.id, role: user.role, user, isAdmin: user.role === 'admin' && user.status === 'approved' };
48
50
  }
49
-
50
51
  /**
51
52
  * The one auth gate for the whole handler. Fails closed: with auth on, a
52
53
  * request either resolves to a real user or carries `userId: undefined`,
53
- * which every scoped store method refuses. 'none' is a single-person
54
+ * which every scoped store method refuses. 'local' is a single-person
54
55
  * install and sees everything, exactly as before auth existed. A refusal
55
56
  * comes back as one of notFound / unauthorized / disabled / pending.
56
57
  */
57
58
  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;
59
+ const auth = resolveIdentity(req, pathname, cfg, store);
60
+ // Features come from the policy for the caller's role; nobody signed in
61
+ // gets none, which leaves only the routes the table marks open.
62
+ auth.features = auth.userId !== undefined ? featuresFor(cfg, auth.role) : new Set();
63
+ if (cfg.authMode === 'trusted-header' && auth.user)
64
+ auth.isAdmin = auth.features.has(FEATURE.ADMIN);
65
+ return auth;
64
66
  }
65
-
66
67
  /** Who to name in the audit log for a change: the user, or 'local' (tiers 1-2). */
67
68
  export const actor = (auth) => (auth.user?.id && auth.user.id !== OWNER_ID ? auth.user.id : 'local');
@@ -0,0 +1,17 @@
1
+ export const normalizeEmail = (value) => typeof value === 'string' ? value.trim().toLowerCase() : '';
2
+ export function validEmail(email) {
3
+ if (typeof email !== 'string' || email.length > 320)
4
+ return false;
5
+ const parts = email.split('@');
6
+ if (parts.length !== 2)
7
+ return false;
8
+ const [local, domain] = parts;
9
+ if (!local || local.length > 64 || !domain || domain.length > 253)
10
+ return false;
11
+ if (local.startsWith('.') || local.endsWith('.') || local.includes('..'))
12
+ return false;
13
+ if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local))
14
+ return false;
15
+ const labels = domain.split('.');
16
+ return labels.length >= 2 && labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
17
+ }