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.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/access/auth.js
CHANGED
|
@@ -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
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
return createHmac('sha256', secret).update(value).digest('hex');
|
|
24
22
|
}
|
|
25
|
-
|
|
26
23
|
function verify(value, sig, secret) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
35
|
+
store.users.deleteSession(sha256(token));
|
|
41
36
|
}
|
|
42
|
-
|
|
43
37
|
export function destroyUserSessions(store, userId) {
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
}
|
package/src/access/auth_gate.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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. '
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|