tacilytics 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/auth.js ADDED
@@ -0,0 +1,788 @@
1
+ 'use strict';
2
+ /* Tacilytics — accounts, team, optional 2FA (TOTP) and passkeys (WebAuthn).
3
+ *
4
+ * Two modes, decided by whether the users table is empty:
5
+ * - Legacy (no accounts yet): sign in with WEB_PASSWORD, exactly like before 2.1.
6
+ * The signed-in owner can create the first account from Settings → Team.
7
+ * - Accounts (≥ 1 user): WEB_PASSWORD no longer opens anything (otherwise it would be a
8
+ * way around 2FA). Sign in with username + password (+ TOTP if enabled) or a passkey.
9
+ * API_KEY (Bearer) keeps owner rights in both modes — it is for agents / the CLI.
10
+ *
11
+ * Roles: owner (everything incl. team) > admin (sites, shares, funnels) > viewer (read only,
12
+ * optionally limited to a list of sites).
13
+ *
14
+ * Secrets at rest: passwords = scrypt, session/invite tokens + recovery codes = sha256,
15
+ * TOTP secrets are stored as-is (they must be readable to verify codes).
16
+ */
17
+ const crypto = require('crypto');
18
+ const PKG_VERSION = require('./package.json').version;
19
+
20
+ const ROLES = { viewer: 1, admin: 2, owner: 3 };
21
+ const SESSION_DAYS = 30;
22
+ const INVITE_DAYS = 7;
23
+ const RESET_HOURS = 24;
24
+ const MIN_PW = 10;
25
+
26
+ // ── small crypto helpers ───────────────────────────────────────────────────
27
+ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
28
+ const randToken = (n = 24) => crypto.randomBytes(n).toString('hex');
29
+ function safeEq(a, b) {
30
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
31
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
32
+ }
33
+ const SCRYPT = { N: 16384, r: 8, p: 1 };
34
+ const scryptAsync = (pw, salt, len, o) => new Promise((ok, no) => crypto.scrypt(pw, salt, len, o, (e, k) => (e ? no(e) : ok(k))));
35
+ function fmtHash(salt, h) { return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64')}$${h.toString('base64')}`; }
36
+ // sync variants: only for module start and the offline CLI (no event loop to block there)
37
+ function hashPasswordSync(pw) {
38
+ const salt = crypto.randomBytes(16);
39
+ return fmtHash(salt, crypto.scryptSync(String(pw), salt, 32, SCRYPT));
40
+ }
41
+ // async variants: everything that runs inside a request, so a login never blocks /api/collect
42
+ async function hashPassword(pw) {
43
+ const salt = crypto.randomBytes(16);
44
+ return fmtHash(salt, await scryptAsync(String(pw), salt, 32, SCRYPT));
45
+ }
46
+ async function verifyPassword(pw, stored) {
47
+ const parts = String(stored || '').split('$');
48
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
49
+ const [, N, r, p, salt, hash] = parts;
50
+ const want = Buffer.from(hash, 'base64');
51
+ const got = await scryptAsync(String(pw), Buffer.from(salt, 'base64'), want.length, { N: +N, r: +r, p: +p });
52
+ return crypto.timingSafeEqual(got, want);
53
+ }
54
+ // a fixed dummy hash so unknown usernames cost the same time as wrong passwords
55
+ const DUMMY_HASH = hashPasswordSync(randToken());
56
+
57
+ // ── TOTP (RFC 6238: HMAC-SHA1, 30 s, 6 digits) ────────────────────────────
58
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
59
+ function base32Encode(buf) {
60
+ let bits = 0, val = 0, out = '';
61
+ for (const byte of buf) {
62
+ val = (val << 8) | byte; bits += 8;
63
+ while (bits >= 5) { out += B32[(val >>> (bits - 5)) & 31]; bits -= 5; }
64
+ }
65
+ if (bits > 0) out += B32[(val << (5 - bits)) & 31];
66
+ return out;
67
+ }
68
+ function base32Decode(str) {
69
+ const s = String(str).toUpperCase().replace(/=+$/, '').replace(/\s/g, '');
70
+ let bits = 0, val = 0; const out = [];
71
+ for (const ch of s) {
72
+ const i = B32.indexOf(ch); if (i < 0) throw new Error('bad base32');
73
+ val = (val << 5) | i; bits += 5;
74
+ if (bits >= 8) { out.push((val >>> (bits - 8)) & 255); bits -= 8; }
75
+ }
76
+ return Buffer.from(out);
77
+ }
78
+ function totpAt(secretB32, step) {
79
+ const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(step));
80
+ const h = crypto.createHmac('sha1', base32Decode(secretB32)).update(msg).digest();
81
+ const o = h[h.length - 1] & 15;
82
+ const code = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
83
+ return String(code % 1e6).padStart(6, '0');
84
+ }
85
+ /** returns the matched time step (for replay protection) or -1 */
86
+ function totpCheck(secretB32, code, now = Date.now()) {
87
+ const c = String(code || '').replace(/\s/g, '');
88
+ if (!/^\d{6}$/.test(c)) return -1;
89
+ const step = Math.floor(now / 30000);
90
+ for (const d of [0, -1, 1]) if (safeEq(totpAt(secretB32, step + d), c)) return step + d;
91
+ return -1;
92
+ }
93
+ function newRecoveryCodes() {
94
+ // 10 codes like "k3f9-x2qa-7mde", alphabet without look-alikes
95
+ const A = 'abcdefghjkmnpqrstuvwxyz23456789';
96
+ return Array.from({ length: 10 }, () => {
97
+ const b = crypto.randomBytes(12);
98
+ let s = ''; for (let i = 0; i < 12; i++) s += A[b[i] % A.length];
99
+ return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
100
+ });
101
+ }
102
+ const normRecovery = (c) => String(c || '').toLowerCase().replace(/[^a-z0-9]/g, '');
103
+
104
+ // ── base64url helpers for WebAuthn ─────────────────────────────────────────
105
+ const b64u = (buf) => Buffer.from(buf).toString('base64url');
106
+ const fromB64u = (s) => new Uint8Array(Buffer.from(String(s), 'base64url'));
107
+
108
+ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, siteIdByDomain, allSiteIds }) {
109
+ const webauthn = require('@simplewebauthn/server');
110
+ const QRCode = require('qrcode');
111
+
112
+ db.exec(`
113
+ CREATE TABLE IF NOT EXISTS users (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
116
+ pw_hash TEXT NOT NULL,
117
+ role TEXT NOT NULL DEFAULT 'viewer',
118
+ sites TEXT, -- JSON array of site ids; NULL = all sites
119
+ totp_secret TEXT, -- set once 2FA is enabled
120
+ totp_pending TEXT, -- secret shown during setup, not active yet
121
+ totp_last_step INTEGER, -- last accepted TOTP step (no code reuse)
122
+ recovery TEXT, -- JSON array of sha256(code)
123
+ webauthn_id TEXT NOT NULL, -- random user handle for passkeys (base64url)
124
+ created INTEGER NOT NULL,
125
+ last_login INTEGER
126
+ );
127
+ CREATE TABLE IF NOT EXISTS passkeys (
128
+ id TEXT PRIMARY KEY, -- credential id (base64url)
129
+ user_id INTEGER NOT NULL,
130
+ public_key BLOB NOT NULL,
131
+ counter INTEGER NOT NULL DEFAULT 0,
132
+ transports TEXT,
133
+ name TEXT,
134
+ created INTEGER NOT NULL,
135
+ last_used INTEGER
136
+ );
137
+ CREATE TABLE IF NOT EXISTS sessions (
138
+ token_hash TEXT PRIMARY KEY,
139
+ user_id INTEGER, -- NULL = legacy WEB_PASSWORD session
140
+ created INTEGER NOT NULL,
141
+ expires INTEGER NOT NULL
142
+ );
143
+ CREATE TABLE IF NOT EXISTS audit_log (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ ts INTEGER NOT NULL,
146
+ actor TEXT, -- username, 'API key', 'passphrase', or NULL (public: invite / reset link)
147
+ action TEXT NOT NULL, -- e.g. 'site.add', 'team.invite', 'me.totp.enable'
148
+ target TEXT, -- what it was about (domain, username, …) — never a secret
149
+ ip TEXT -- address as seen through the proxy; for "was that me?"
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
152
+ CREATE TABLE IF NOT EXISTS api_keys (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ key_hash TEXT NOT NULL UNIQUE,
155
+ prefix TEXT NOT NULL, -- first characters, shown in the list so people can tell keys apart
156
+ user_id INTEGER NOT NULL,
157
+ name TEXT,
158
+ read_only INTEGER NOT NULL DEFAULT 1,
159
+ created INTEGER NOT NULL,
160
+ last_used INTEGER
161
+ );
162
+ CREATE TABLE IF NOT EXISTS password_resets (
163
+ token_hash TEXT PRIMARY KEY,
164
+ user_id INTEGER NOT NULL,
165
+ created_by INTEGER,
166
+ created INTEGER NOT NULL,
167
+ expires INTEGER NOT NULL,
168
+ used_at INTEGER
169
+ );
170
+ CREATE TABLE IF NOT EXISTS invites (
171
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
172
+ token_hash TEXT NOT NULL UNIQUE,
173
+ role TEXT NOT NULL,
174
+ sites TEXT,
175
+ created_by INTEGER,
176
+ created INTEGER NOT NULL,
177
+ expires INTEGER NOT NULL,
178
+ used_at INTEGER,
179
+ used_by INTEGER
180
+ );
181
+ `);
182
+
183
+ const q = {
184
+ userCount: db.prepare('SELECT COUNT(*) n FROM users'),
185
+ ownerCount: db.prepare("SELECT COUNT(*) n FROM users WHERE role = 'owner'"),
186
+ userById: db.prepare('SELECT * FROM users WHERE id = ?'),
187
+ userByName: db.prepare('SELECT * FROM users WHERE username = ?'),
188
+ userByHandle: db.prepare('SELECT * FROM users WHERE webauthn_id = ?'),
189
+ insUser: db.prepare('INSERT INTO users (username, pw_hash, role, sites, webauthn_id, created) VALUES (?, ?, ?, ?, ?, ?)'),
190
+ sessGet: db.prepare('SELECT * FROM sessions WHERE token_hash = ?'),
191
+ sessIns: db.prepare('INSERT INTO sessions (token_hash, user_id, created, expires) VALUES (?, ?, ?, ?)'),
192
+ sessDel: db.prepare('DELETE FROM sessions WHERE token_hash = ?'),
193
+ sessDelUser: db.prepare('DELETE FROM sessions WHERE user_id = ?'),
194
+ sessDelUserExcept: db.prepare('DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?'),
195
+ sessDelLegacy: db.prepare('DELETE FROM sessions WHERE user_id IS NULL'),
196
+ sessPurge: db.prepare('DELETE FROM sessions WHERE expires < ?'),
197
+ pkByUser: db.prepare('SELECT id, name, created, last_used, transports FROM passkeys WHERE user_id = ? ORDER BY created'),
198
+ pkGet: db.prepare('SELECT * FROM passkeys WHERE id = ?'),
199
+ };
200
+ const hasUsers = () => q.userCount.get().n > 0;
201
+
202
+ // ── audit log ──────────────────────────────────────────────────────────────
203
+ // Every successful state change (POST/PATCH/DELETE answered with 2xx) gets one row, written
204
+ // after the response is sent. Handlers can name the action/target via res.locals.audit;
205
+ // otherwise method + route are recorded. New endpoints are covered without remembering to.
206
+ // Not logged: tracking beacons, sign-in steps, WebAuthn option requests (no state change).
207
+ const AUDIT_SKIP = new Set(['/api/collect', '/auth/login', '/auth/login/totp', '/auth/passkey/options', '/auth/passkey/verify', '/auth/logout', '/api/me/passkeys/options', '/api/me/totp/setup']);
208
+ const AUDIT_DAYS = Math.max(30, parseInt(process.env.AUDIT_RETENTION_DAYS, 10) || 365);
209
+ const insAudit = db.prepare('INSERT INTO audit_log (ts, actor, action, target, ip) VALUES (?, ?, ?, ?, ?)');
210
+ function actorOf(p) {
211
+ if (!p) return null;
212
+ if (p.kind === 'user') return p.user.username;
213
+ if (p.kind === 'userkey') return p.user.username + ' (API key)';
214
+ if (p.kind === 'apikey') return 'API key';
215
+ if (p.kind === 'legacy') return 'passphrase';
216
+ return null;
217
+ }
218
+ app.use((req, res, next) => {
219
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS' || AUDIT_SKIP.has(req.path)) return next();
220
+ res.on('finish', () => {
221
+ if (res.statusCode < 200 || res.statusCode >= 300) return;
222
+ try {
223
+ const a = res.locals.audit || {};
224
+ const route = req.route ? req.route.path : req.path;
225
+ const action = a.action || `${req.method} ${route}`;
226
+ const actor = a.actor !== undefined ? a.actor : actorOf(req.principal);
227
+ insAudit.run(Date.now(), actor, action, a.target == null ? null : String(a.target).slice(0, 200), clientIp(req));
228
+ } catch (e) { console.error('audit log:', e.message); }
229
+ });
230
+ next();
231
+ });
232
+ setInterval(() => db.prepare('DELETE FROM audit_log WHERE ts < ?').run(Date.now() - AUDIT_DAYS * 864e5), 6 * 3600e3).unref();
233
+ const audit = (res, action, target, actor) => { res.locals.audit = { action, target, ...(actor !== undefined ? { actor } : {}) }; };
234
+ if (hasUsers() && !process.env.TACILYTICS_ORIGIN) {
235
+ console.warn('⚠️ TACILYTICS_ORIGIN is not set. Passkeys use the request host, so they break if the dashboard is reached under another host/port or behind a proxy without X-Forwarded-Proto. Set it to the public dashboard URL.');
236
+ }
237
+ setInterval(() => q.sessPurge.run(Date.now()), 3600e3).unref();
238
+
239
+ // ── sessions ──
240
+ function createSession(userId) {
241
+ const token = randToken();
242
+ q.sessIns.run(sha256(token), userId, Date.now(), Date.now() + SESSION_DAYS * 864e5);
243
+ if (userId) db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(Date.now(), userId);
244
+ return token;
245
+ }
246
+ function principal(req) {
247
+ // agents / CLI: API key = owner over everything
248
+ const hdr = String(req.headers['authorization'] || '');
249
+ const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
250
+ if (key) {
251
+ // the server-wide API_KEY from .env: owner over everything (agents / CLI on your own box)
252
+ if (API_KEY && safeEq(key, API_KEY)) return { kind: 'apikey', role: 'owner', sites: null, user: null };
253
+ // personal keys (tk_…): act as their owner, never more — and read-only unless created otherwise
254
+ if (key.startsWith('tk_')) {
255
+ const k = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?').get(sha256(key));
256
+ const u = k && q.userById.get(k.user_id);
257
+ if (!u) return null;
258
+ if (!k.last_used || Date.now() - k.last_used > 60000) db.prepare('UPDATE api_keys SET last_used = ? WHERE id = ?').run(Date.now(), k.id);
259
+ const role = k.read_only ? 'viewer' : u.role;
260
+ return { kind: 'userkey', role, sites: u.sites ? JSON.parse(u.sites) : null, user: u, keyId: k.id };
261
+ }
262
+ return null; // a wrong key is a failed sign-in, not an anonymous request
263
+ }
264
+ const t = String(req.headers['x-session-token'] || '');
265
+ if (!t) return null;
266
+ const s = q.sessGet.get(sha256(t));
267
+ if (!s) return null;
268
+ if (s.expires < Date.now()) { q.sessDel.run(s.token_hash); return null; }
269
+ if (s.user_id == null) {
270
+ // legacy session only valid while there are no accounts
271
+ if (hasUsers()) { q.sessDel.run(s.token_hash); return null; }
272
+ return { kind: 'legacy', role: 'owner', sites: null, user: null, tokenHash: s.token_hash };
273
+ }
274
+ const u = q.userById.get(s.user_id);
275
+ if (!u) { q.sessDel.run(s.token_hash); return null; }
276
+ return { kind: 'user', role: u.role, sites: u.sites ? JSON.parse(u.sites) : null, user: u, tokenHash: s.token_hash };
277
+ }
278
+ function auth(req, res, next) {
279
+ const p = principal(req);
280
+ if (!p) return res.status(401).json({ error: 'Not authenticated' });
281
+ req.principal = p;
282
+ next();
283
+ }
284
+ const requireRole = (role) => (req, res, next) => auth(req, res, () => {
285
+ if (ROLES[req.principal.role] >= ROLES[role]) return next();
286
+ res.status(403).json({ error: 'Your role does not allow this' });
287
+ });
288
+ /** may the current principal (or share link) see this site id? */
289
+ function siteAllowed(req, siteId) {
290
+ if (req.shareSiteId != null) return req.shareSiteId === siteId;
291
+ const p = req.principal;
292
+ if (!p) return false;
293
+ return p.sites == null || p.sites.includes(siteId);
294
+ }
295
+
296
+ // ── helpers ──
297
+ const cleanName = (s) => String(s || '').trim().slice(0, 40);
298
+ const validName = (s) => /^[A-Za-z0-9._@-]{2,40}$/.test(s);
299
+ function cleanSites(raw) {
300
+ if (raw == null) return null;
301
+ if (!Array.isArray(raw)) return undefined;
302
+ const valid = new Set(allSiteIds());
303
+ const ids = [...new Set(raw.map(Number).filter((n) => valid.has(n)))];
304
+ return ids;
305
+ }
306
+ const cleanRole = (r) => (Object.prototype.hasOwnProperty.call(ROLES, r) ? r : null);
307
+ function publicUser(u) {
308
+ return {
309
+ id: u.id, username: u.username, role: u.role, sites: u.sites ? JSON.parse(u.sites) : null,
310
+ totp: !!u.totp_secret, passkeys: q.pkByUser.all(u.id).length,
311
+ created: u.created, last_login: u.last_login,
312
+ };
313
+ }
314
+ function rpFor(req) {
315
+ // TACILYTICS_ORIGIN pins the passkey domain (recommended behind proxies). Otherwise the
316
+ // request host is used: a passkey signs the hash of its own rpID, so a forged Host header
317
+ // cannot produce a valid assertion for another domain.
318
+ const fixed = process.env.TACILYTICS_ORIGIN;
319
+ const origin = fixed ? fixed.replace(/\/$/, '') : `${req.protocol}://${req.get('host')}`;
320
+ return { origin, rpID: new URL(origin).hostname };
321
+ }
322
+ // short-lived in-memory state: login tickets (password ok, waiting for TOTP) + WebAuthn challenges
323
+ const pending = new Map();
324
+ const PENDING_MAX = 10000; // hard cap: /auth/passkey/options is unauthenticated, the map must not grow without bound
325
+ function putPending(kind, data, ms = 5 * 60000, expires = null) {
326
+ if (pending.size >= PENDING_MAX) {
327
+ const n = Date.now();
328
+ for (const [k, v] of pending) if (v.expires < n) pending.delete(k);
329
+ if (pending.size >= PENDING_MAX) return null;
330
+ }
331
+ const id = randToken(16);
332
+ pending.set(id, { kind, data, expires: expires || Date.now() + ms });
333
+ return id;
334
+ }
335
+ function takePending(id, kind) {
336
+ const e = pending.get(String(id || ''));
337
+ if (!e) return null;
338
+ pending.delete(String(id));
339
+ if (e.kind !== kind || e.expires < Date.now()) return null;
340
+ return { ...e.data, expires: e.expires };
341
+ }
342
+ const BUSY = (res) => res.status(503).json({ error: 'Too many sign-ins in progress — try again in a minute' });
343
+ const TOTP_TRIES = 5; // wrong codes per password entry; after that the password is needed again
344
+ setInterval(() => { const n = Date.now(); for (const [k, v] of pending) if (v.expires < n) pending.delete(k); }, 60000).unref();
345
+
346
+ function checkSecondFactor(u, code) {
347
+ // TOTP first, then a one-time recovery code
348
+ const step = totpCheck(u.totp_secret, code);
349
+ if (step >= 0) {
350
+ if (u.totp_last_step != null && step <= u.totp_last_step) return false; // replay
351
+ db.prepare('UPDATE users SET totp_last_step = ? WHERE id = ?').run(step, u.id);
352
+ return true;
353
+ }
354
+ const rc = normRecovery(code);
355
+ if (rc.length !== 12) return false;
356
+ const list = JSON.parse(u.recovery || '[]');
357
+ const h = sha256(rc);
358
+ const i = list.findIndex((x) => safeEq(x, h));
359
+ if (i < 0) return false;
360
+ list.splice(i, 1);
361
+ db.prepare('UPDATE users SET recovery = ? WHERE id = ?').run(JSON.stringify(list), u.id);
362
+ return true;
363
+ }
364
+
365
+ // ── public auth endpoints ──────────────────────────────────────────────────
366
+ app.get('/auth/status', (_req, res) => res.json({ accounts: hasUsers() }));
367
+
368
+ app.post('/auth/login', async (req, res) => {
369
+ const ip = clientIp(req);
370
+ if (rateLimited('login:' + ip, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
371
+ const b = req.body || {};
372
+ const pw = String(b.password || '');
373
+ if (!hasUsers()) {
374
+ if (!safeEq(pw, WEB_PASSWORD)) return res.status(401).json({ error: 'Wrong password' });
375
+ return res.json({ token: createSession(null) });
376
+ }
377
+ const name = cleanName(b.username);
378
+ if (name && rateLimited('login-user:' + name.toLowerCase(), 20, 60 * 60000)) return res.status(429).json({ error: 'Too many attempts for this account — wait an hour' });
379
+ const u = name ? q.userByName.get(name) : null;
380
+ const ok = (await verifyPassword(pw, u ? u.pw_hash : DUMMY_HASH)) && !!u;
381
+ if (!ok) return res.status(401).json({ error: 'Wrong username or password' });
382
+ if (u.totp_secret) {
383
+ const ticket = putPending('mfa', { userId: u.id, tries: 0 });
384
+ return ticket ? res.json({ mfa: 'totp', ticket }) : BUSY(res);
385
+ }
386
+ res.json({ token: createSession(u.id) });
387
+ });
388
+
389
+ app.post('/auth/login/totp', (req, res) => {
390
+ if (rateLimited('totp:' + clientIp(req), 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
391
+ const b = req.body || {};
392
+ const t = takePending(b.ticket, 'mfa');
393
+ if (!t) return res.status(401).json({ error: 'Sign-in expired — enter your password again' });
394
+ const u = q.userById.get(t.userId);
395
+ if (!u || !u.totp_secret) return res.status(401).json({ error: 'Sign-in expired — enter your password again' });
396
+ // per account, independent of the IP: guessing codes from many addresses does not help
397
+ if (rateLimited('totp-user:' + u.id, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many wrong codes for this account — wait 15 minutes' });
398
+ if (!checkSecondFactor(u, b.code)) {
399
+ const tries = (t.tries || 0) + 1;
400
+ if (tries >= TOTP_TRIES) return res.status(401).json({ error: 'Too many wrong codes — enter your password again' });
401
+ // one more try without re-entering the password; the successor keeps the original expiry and the count
402
+ const ticket = putPending('mfa', { userId: u.id, tries }, 0, t.expires);
403
+ return res.status(401).json(ticket ? { error: 'Wrong code', ticket } : { error: 'Wrong code — enter your password again' });
404
+ }
405
+ res.json({ token: createSession(u.id) });
406
+ });
407
+
408
+ app.post('/auth/passkey/options', async (req, res) => {
409
+ if (rateLimited('pk:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
410
+ if (!hasUsers()) return res.status(400).json({ error: 'No accounts yet' });
411
+ const { rpID } = rpFor(req);
412
+ const options = await webauthn.generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
413
+ const ticket = putPending('pk-auth', { challenge: options.challenge });
414
+ if (!ticket) return BUSY(res);
415
+ res.json({ options, ticket });
416
+ });
417
+ app.post('/auth/passkey/verify', async (req, res) => {
418
+ if (rateLimited('pk:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
419
+ const b = req.body || {};
420
+ const t = takePending(b.ticket, 'pk-auth');
421
+ const fail = () => res.status(401).json({ error: 'Passkey not accepted' });
422
+ if (!t || !b.response || typeof b.response.id !== 'string') return fail();
423
+ const pk = q.pkGet.get(b.response.id);
424
+ if (!pk) return fail();
425
+ const u = q.userById.get(pk.user_id);
426
+ if (!u) return fail();
427
+ // the authenticator's user handle must belong to the same account as the credential
428
+ const handle = b.response.response && b.response.response.userHandle;
429
+ if (handle && handle !== u.webauthn_id) return fail();
430
+ const { origin, rpID } = rpFor(req);
431
+ try {
432
+ const v = await webauthn.verifyAuthenticationResponse({
433
+ response: b.response, expectedChallenge: t.challenge, expectedOrigin: origin, expectedRPID: rpID,
434
+ credential: { id: pk.id, publicKey: new Uint8Array(pk.public_key), counter: pk.counter, transports: pk.transports ? JSON.parse(pk.transports) : undefined },
435
+ requireUserVerification: false,
436
+ });
437
+ if (!v.verified) return fail();
438
+ db.prepare('UPDATE passkeys SET counter = ?, last_used = ? WHERE id = ?').run(v.authenticationInfo.newCounter, Date.now(), pk.id);
439
+ } catch { return fail(); }
440
+ // a passkey is possession + (usually) biometrics: it replaces password and TOTP
441
+ res.json({ token: createSession(u.id) });
442
+ });
443
+
444
+ app.post('/auth/logout', (req, res) => {
445
+ const t = String(req.headers['x-session-token'] || '');
446
+ if (t) q.sessDel.run(sha256(t));
447
+ res.json({ ok: true });
448
+ });
449
+
450
+ // ── invites (public part) ──
451
+ function inviteByToken(token) {
452
+ const inv = db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(sha256(String(token || '')));
453
+ if (!inv || inv.used_at || inv.expires < Date.now()) return null;
454
+ return inv;
455
+ }
456
+ app.get('/api/invite/:token', (req, res) => {
457
+ if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
458
+ const inv = inviteByToken(req.params.token);
459
+ if (!inv) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
460
+ res.json({ role: inv.role, expires: inv.expires });
461
+ });
462
+ app.post('/api/invite/:token', async (req, res) => {
463
+ if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
464
+ const b = req.body || {};
465
+ const name = cleanName(b.username), pw = String(b.password || '');
466
+ if (!validName(name)) return res.status(400).json({ error: 'Username: 2–40 characters, letters, digits, . _ @ -' });
467
+ if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
468
+ if (q.userByName.get(name)) return res.status(409).json({ error: 'This username is taken' });
469
+ const pwHash = await hashPassword(pw);
470
+ const tx = db.transaction(() => {
471
+ const inv = inviteByToken(req.params.token);
472
+ if (!inv) return null;
473
+ if (q.userByName.get(name)) return 'taken'; // re-checked inside the transaction (two sign-ups racing for one name)
474
+ const info = q.insUser.run(name, pwHash, inv.role, inv.sites, b64u(crypto.randomBytes(16)), Date.now());
475
+ db.prepare('UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?').run(Date.now(), info.lastInsertRowid, inv.id);
476
+ return info.lastInsertRowid;
477
+ });
478
+ const uid = tx();
479
+ if (uid === 'taken') return res.status(409).json({ error: 'This username is taken' });
480
+ if (!uid) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
481
+ audit(res, 'team.join', name, name);
482
+ res.json({ token: createSession(uid) });
483
+ });
484
+
485
+ // ── me ─────────────────────────────────────────────────────────────────────
486
+ app.get('/api/me', auth, (req, res) => {
487
+ const p = req.principal;
488
+ // version only behind auth: a public number would tell scanners which instances still run a release
489
+ // with a known hole (Tyto). Never in /auth/status, the login screen or share links.
490
+ const out = { kind: p.kind, role: p.role, accounts: hasUsers(), version: PKG_VERSION };
491
+ if (p.user) {
492
+ const u = q.userById.get(p.user.id);
493
+ Object.assign(out, publicUser(u), {
494
+ recoveryLeft: JSON.parse(u.recovery || '[]').length,
495
+ passkeyList: q.pkByUser.all(u.id).map((k) => ({ id: k.id, name: k.name, created: k.created, last_used: k.last_used })),
496
+ });
497
+ }
498
+ res.json(out);
499
+ });
500
+ const userOnly = (req, res, next) => {
501
+ if (req.principal.kind === 'userkey') return res.status(403).json({ error: 'API keys cannot change account settings — sign in' });
502
+ return req.principal.user ? next() : res.status(400).json({ error: 'Create an account first' });
503
+ };
504
+ async function needPassword(req, res) {
505
+ if (rateLimited('reauth:' + req.principal.user.id, 10, 15 * 60000)) { res.status(429).json({ error: 'Too many attempts — wait 15 minutes' }); return false; }
506
+ const u = q.userById.get(req.principal.user.id);
507
+ if (!(await verifyPassword(String((req.body || {}).password || ''), u.pw_hash))) { res.status(401).json({ error: 'Wrong password' }); return false; }
508
+ return true;
509
+ }
510
+
511
+ app.post('/api/me/password', auth, userOnly, async (req, res) => {
512
+ if (!(await needPassword(req, res))) return;
513
+ const npw = String((req.body || {}).newPassword || '');
514
+ if (npw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
515
+ db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(await hashPassword(npw), req.principal.user.id);
516
+ q.sessDelUserExcept.run(req.principal.user.id, req.principal.tokenHash); // sign out other devices
517
+ audit(res, 'me.password');
518
+ res.json({ ok: true });
519
+ });
520
+
521
+ app.post('/api/me/totp/setup', auth, userOnly, async (req, res) => {
522
+ const u = q.userById.get(req.principal.user.id);
523
+ if (u.totp_secret) return res.status(400).json({ error: '2FA is already on' });
524
+ const secret = base32Encode(crypto.randomBytes(20));
525
+ db.prepare('UPDATE users SET totp_pending = ? WHERE id = ?').run(secret, u.id);
526
+ const { rpID } = rpFor(req);
527
+ const label = encodeURIComponent(`Tacilytics (${rpID}):${u.username}`);
528
+ const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent('Tacilytics')}&algorithm=SHA1&digits=6&period=30`;
529
+ const svg = await QRCode.toString(uri, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
530
+ res.json({ secret, uri, qr: 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64') });
531
+ });
532
+ app.post('/api/me/totp/enable', auth, userOnly, (req, res) => {
533
+ if (rateLimited('totp-en:' + req.principal.user.id, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
534
+ const u = q.userById.get(req.principal.user.id);
535
+ if (u.totp_secret) return res.status(400).json({ error: '2FA is already on' });
536
+ if (!u.totp_pending) return res.status(400).json({ error: 'Start the setup first' });
537
+ const step = totpCheck(u.totp_pending, (req.body || {}).code);
538
+ if (step < 0) return res.status(401).json({ error: 'Wrong code — check the time on your phone' });
539
+ const codes = newRecoveryCodes();
540
+ db.prepare('UPDATE users SET totp_secret = totp_pending, totp_pending = NULL, totp_last_step = ?, recovery = ? WHERE id = ?')
541
+ .run(step, JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
542
+ q.sessDelUserExcept.run(u.id, req.principal.tokenHash);
543
+ audit(res, 'me.2fa.on');
544
+ res.json({ ok: true, recoveryCodes: codes });
545
+ });
546
+ app.post('/api/me/totp/disable', auth, userOnly, async (req, res) => {
547
+ if (!(await needPassword(req, res))) return;
548
+ db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(req.principal.user.id);
549
+ audit(res, 'me.2fa.off');
550
+ res.json({ ok: true });
551
+ });
552
+ app.post('/api/me/recovery', auth, userOnly, async (req, res) => {
553
+ if (!(await needPassword(req, res))) return;
554
+ const u = q.userById.get(req.principal.user.id);
555
+ if (!u.totp_secret) return res.status(400).json({ error: '2FA is off' });
556
+ const codes = newRecoveryCodes();
557
+ db.prepare('UPDATE users SET recovery = ? WHERE id = ?').run(JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
558
+ audit(res, 'me.recovery.new');
559
+ res.json({ recoveryCodes: codes });
560
+ });
561
+
562
+ app.post('/api/me/passkeys/options', auth, userOnly, async (req, res) => {
563
+ const u = q.userById.get(req.principal.user.id);
564
+ const { rpID } = rpFor(req);
565
+ const existing = db.prepare('SELECT id, transports FROM passkeys WHERE user_id = ?').all(u.id);
566
+ const options = await webauthn.generateRegistrationOptions({
567
+ rpName: 'Tacilytics', rpID, userName: u.username, userID: fromB64u(u.webauthn_id),
568
+ attestationType: 'none',
569
+ excludeCredentials: existing.map((k) => ({ id: k.id, transports: k.transports ? JSON.parse(k.transports) : undefined })),
570
+ authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
571
+ });
572
+ const ticket = putPending('pk-reg', { challenge: options.challenge, userId: u.id });
573
+ if (!ticket) return BUSY(res);
574
+ res.json({ options, ticket });
575
+ });
576
+ app.post('/api/me/passkeys', auth, userOnly, async (req, res) => {
577
+ const b = req.body || {};
578
+ const t = takePending(b.ticket, 'pk-reg');
579
+ if (!t || t.userId !== req.principal.user.id) return res.status(400).json({ error: 'Setup expired — try again' });
580
+ const { origin, rpID } = rpFor(req);
581
+ let v;
582
+ try {
583
+ v = await webauthn.verifyRegistrationResponse({ response: b.response, expectedChallenge: t.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false });
584
+ } catch (e) { return res.status(400).json({ error: 'Passkey not accepted: ' + String(e.message || e).slice(0, 120) }); }
585
+ if (!v.verified) return res.status(400).json({ error: 'Passkey not accepted' });
586
+ const c = v.registrationInfo.credential;
587
+ if (q.pkGet.get(c.id)) return res.status(409).json({ error: 'This passkey is already registered' });
588
+ const name = String(b.name || '').trim().slice(0, 60) || 'Passkey';
589
+ db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
590
+ .run(c.id, t.userId, Buffer.from(c.publicKey), c.counter || 0, c.transports ? JSON.stringify(c.transports) : null, name, Date.now());
591
+ audit(res, 'me.passkey.add', name);
592
+ res.json({ ok: true });
593
+ });
594
+ app.delete('/api/me/passkeys/:id', auth, userOnly, (req, res) => {
595
+ const pk = db.prepare('SELECT name FROM passkeys WHERE id = ? AND user_id = ?').get(String(req.params.id), req.principal.user.id);
596
+ db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(String(req.params.id), req.principal.user.id);
597
+ audit(res, 'me.passkey.remove', pk ? pk.name : null);
598
+ res.json({ ok: true });
599
+ });
600
+
601
+ app.get('/api/audit', requireRole('owner'), (req, res) => {
602
+ if (req.principal.kind === 'userkey') return res.status(403).json({ error: 'API keys cannot read the audit log — sign in' });
603
+ const before = Number(req.query.before) || Date.now() + 1;
604
+ const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
605
+ res.json(db.prepare('SELECT id, ts, actor, action, target, ip FROM audit_log WHERE ts < ? ORDER BY ts DESC, id DESC LIMIT ?').all(before, limit));
606
+ });
607
+
608
+ // ── personal API keys ──
609
+ app.get('/api/me/keys', auth, userOnly, (req, res) => {
610
+ res.json(db.prepare('SELECT id, prefix, name, read_only, created, last_used FROM api_keys WHERE user_id = ? ORDER BY created').all(req.principal.user.id)
611
+ .map((k) => ({ ...k, read_only: !!k.read_only })));
612
+ });
613
+ app.post('/api/me/keys', auth, userOnly, (req, res) => {
614
+ const b = req.body || {};
615
+ const n = db.prepare('SELECT COUNT(*) n FROM api_keys WHERE user_id = ?').get(req.principal.user.id).n;
616
+ if (n >= 20) return res.status(400).json({ error: 'At most 20 keys per account' });
617
+ // a viewer can only ever get a read-only key
618
+ const readOnly = req.principal.role === 'viewer' ? true : b.readOnly !== false;
619
+ const key = 'tk_' + crypto.randomBytes(24).toString('base64url');
620
+ const name = String(b.name || '').trim().slice(0, 60) || 'API key';
621
+ const info = db.prepare('INSERT INTO api_keys (key_hash, prefix, user_id, name, read_only, created) VALUES (?, ?, ?, ?, ?, ?)')
622
+ .run(sha256(key), key.slice(0, 10), req.principal.user.id, name, readOnly ? 1 : 0, Date.now());
623
+ audit(res, 'me.apikey.add', `${name} (${key.slice(0, 10)}…, ${readOnly ? 'read-only' : 'read + write'})`);
624
+ res.json({ id: info.lastInsertRowid, key, name, read_only: readOnly }); // the key is shown exactly once
625
+ });
626
+ app.delete('/api/me/keys/:id', auth, userOnly, (req, res) => {
627
+ const k = db.prepare('SELECT name, prefix FROM api_keys WHERE id = ? AND user_id = ?').get(Number(req.params.id), req.principal.user.id);
628
+ db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').run(Number(req.params.id), req.principal.user.id);
629
+ audit(res, 'me.apikey.revoke', k ? `${k.name} (${k.prefix}…)` : null);
630
+ res.json({ ok: true });
631
+ });
632
+
633
+ // ── team (owner) ───────────────────────────────────────────────────────────
634
+ // team management needs a real sign-in (or the server API_KEY): a personal key that leaks must not
635
+ // be able to invite, promote or remove people
636
+ const ownerOnly = (req, res, next) => requireRole('owner')(req, res, () => (req.principal.kind === 'userkey'
637
+ ? res.status(403).json({ error: 'API keys cannot manage the team — sign in' }) : next()));
638
+
639
+ // first account: only from a legacy (WEB_PASSWORD) or API-key session, only while there are none
640
+ app.post('/api/team/bootstrap', ownerOnly, async (req, res) => {
641
+ if (hasUsers()) return res.status(409).json({ error: 'Accounts already exist' });
642
+ const b = req.body || {};
643
+ const name = cleanName(b.username), pw = String(b.password || '');
644
+ if (!validName(name)) return res.status(400).json({ error: 'Username: 2–40 characters, letters, digits, . _ @ -' });
645
+ if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
646
+ const info = q.insUser.run(name, await hashPassword(pw), 'owner', null, b64u(crypto.randomBytes(16)), Date.now());
647
+ q.sessDelLegacy.run(); // WEB_PASSWORD sessions end here
648
+ audit(res, 'team.first-owner', name);
649
+ res.json({ token: createSession(info.lastInsertRowid) });
650
+ });
651
+
652
+ app.get('/api/team', ownerOnly, (_req, res) => {
653
+ const users = db.prepare('SELECT * FROM users ORDER BY created').all().map(publicUser);
654
+ const invites = db.prepare('SELECT id, role, sites, created, expires FROM invites WHERE used_at IS NULL AND expires > ? ORDER BY created DESC').all(Date.now())
655
+ .map((i) => ({ ...i, sites: i.sites ? JSON.parse(i.sites) : null }));
656
+ res.json({ users, invites });
657
+ });
658
+ app.post('/api/team/invites', ownerOnly, (req, res) => {
659
+ if (!hasUsers()) return res.status(400).json({ error: 'Create your own account first' });
660
+ const b = req.body || {};
661
+ const role = cleanRole(b.role);
662
+ if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
663
+ const sites = role === 'viewer' ? cleanSites(b.sites) : null;
664
+ if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
665
+ if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
666
+ const token = randToken();
667
+ const expires = Date.now() + INVITE_DAYS * 864e5;
668
+ db.prepare('INSERT INTO invites (token_hash, role, sites, created_by, created, expires) VALUES (?, ?, ?, ?, ?, ?)')
669
+ .run(sha256(token), role, sites ? JSON.stringify(sites) : null, req.principal.user ? req.principal.user.id : null, Date.now(), expires);
670
+ audit(res, 'team.invite', role + (sites ? ' · sites ' + sites.join(',') : ''));
671
+ res.json({ url: '/invite/' + token, role, expires });
672
+ });
673
+ app.delete('/api/team/invites/:id', ownerOnly, (req, res) => {
674
+ db.prepare('DELETE FROM invites WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
675
+ audit(res, 'team.invite.revoke', '#' + Number(req.params.id));
676
+ res.json({ ok: true });
677
+ });
678
+ app.patch('/api/team/users/:id', ownerOnly, (req, res) => {
679
+ const u = q.userById.get(Number(req.params.id));
680
+ if (!u) return res.status(404).json({ error: 'Unknown user' });
681
+ const b = req.body || {};
682
+ const role = b.role === undefined ? u.role : cleanRole(b.role);
683
+ if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
684
+ if (u.role === 'owner' && role !== 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
685
+ let sites = role === 'viewer' ? (b.sites === undefined ? (u.sites ? JSON.parse(u.sites) : null) : cleanSites(b.sites)) : null;
686
+ if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
687
+ if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
688
+ db.prepare('UPDATE users SET role = ?, sites = ? WHERE id = ?').run(role, sites ? JSON.stringify(sites) : null, u.id);
689
+ audit(res, 'team.change', `${u.username}: ${u.role} → ${role}` + (sites ? ' · sites ' + sites.join(',') : ''));
690
+ res.json(publicUser(q.userById.get(u.id)));
691
+ });
692
+ app.delete('/api/team/users/:id', ownerOnly, (req, res) => {
693
+ const u = q.userById.get(Number(req.params.id));
694
+ if (!u) return res.status(404).json({ error: 'Unknown user' });
695
+ if (u.role === 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
696
+ db.transaction(() => {
697
+ db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(u.id);
698
+ db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(u.id);
699
+ db.prepare('DELETE FROM api_keys WHERE user_id = ?').run(u.id);
700
+ q.sessDelUser.run(u.id);
701
+ db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
702
+ })();
703
+ audit(res, 'team.remove', u.username);
704
+ res.json({ ok: true });
705
+ });
706
+ // owner can reset 2FA of a teammate who lost their phone (the teammate then sets it up again)
707
+ app.post('/api/team/users/:id/reset-2fa', ownerOnly, (req, res) => {
708
+ const u = q.userById.get(Number(req.params.id));
709
+ if (!u) return res.status(404).json({ error: 'Unknown user' });
710
+ db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(u.id);
711
+ q.sessDelUser.run(u.id);
712
+ audit(res, 'team.2fa.reset', u.username);
713
+ res.json({ ok: true });
714
+ });
715
+
716
+ // owner creates a one-time password-reset link for a teammate (24 h). Only the password changes:
717
+ // 2FA stays on (a reset link must not be a way around it) and all their sessions end.
718
+ app.post('/api/team/users/:id/reset-link', ownerOnly, (req, res) => {
719
+ const u = q.userById.get(Number(req.params.id));
720
+ if (!u) return res.status(404).json({ error: 'Unknown user' });
721
+ const token = randToken();
722
+ const expires = Date.now() + RESET_HOURS * 3600e3;
723
+ db.transaction(() => {
724
+ db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(u.id); // only the newest link works
725
+ db.prepare('INSERT INTO password_resets (token_hash, user_id, created_by, created, expires) VALUES (?, ?, ?, ?, ?)')
726
+ .run(sha256(token), u.id, req.principal.user ? req.principal.user.id : null, Date.now(), expires);
727
+ })();
728
+ audit(res, 'team.reset-link', u.username);
729
+ res.json({ url: '/reset/' + token, username: u.username, expires });
730
+ });
731
+ function resetByToken(token) {
732
+ const r = db.prepare('SELECT * FROM password_resets WHERE token_hash = ?').get(sha256(String(token || '')));
733
+ if (!r || r.used_at || r.expires < Date.now()) return null;
734
+ return r;
735
+ }
736
+ app.get('/api/reset/:token', (req, res) => {
737
+ if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
738
+ const r = resetByToken(req.params.token);
739
+ const u = r && q.userById.get(r.user_id);
740
+ if (!u) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
741
+ res.json({ username: u.username, totp: !!u.totp_secret, expires: r.expires });
742
+ });
743
+ app.post('/api/reset/:token', async (req, res) => {
744
+ if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
745
+ const pw = String((req.body || {}).password || '');
746
+ if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
747
+ if (!resetByToken(req.params.token)) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
748
+ const pwHash = await hashPassword(pw); // async scrypt, outside the (synchronous) transaction
749
+ const uid = db.transaction(() => {
750
+ const r = resetByToken(req.params.token); // checked again: used/expired while hashing?
751
+ if (!r || !q.userById.get(r.user_id)) return null;
752
+ db.prepare('UPDATE password_resets SET used_at = ? WHERE token_hash = ?').run(Date.now(), r.token_hash);
753
+ db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(pwHash, r.user_id);
754
+ q.sessDelUser.run(r.user_id);
755
+ return r.user_id;
756
+ })();
757
+ if (!uid) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
758
+ // no session here: the person signs in normally (with 2FA, if they have it)
759
+ const who = q.userById.get(uid).username;
760
+ audit(res, 'me.password.reset-link', who, who);
761
+ res.json({ ok: true, username: who });
762
+ });
763
+
764
+ return { auth, requireRole, siteAllowed, principal, audit };
765
+ }
766
+
767
+ // ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
768
+ function adminOps(db) {
769
+ const has = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
770
+ if (!has) return { list: () => [], reset: () => { throw new Error('No accounts yet'); } };
771
+ return {
772
+ list: () => db.prepare('SELECT id, username, role, totp_secret IS NOT NULL AS totp, last_login FROM users ORDER BY id').all(),
773
+ /** new random password, 2FA off, all sessions of that user ended. Returns the password. */
774
+ reset(username) {
775
+ const u = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(String(username));
776
+ if (!u) throw new Error('Unknown user: ' + username);
777
+ const pw = crypto.randomBytes(12).toString('base64url');
778
+ db.prepare('UPDATE users SET pw_hash = ?, totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(hashPasswordSync(pw), u.id);
779
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
780
+ // the dashboard's activity log should show this too (the CLI bypasses the HTTP audit middleware)
781
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'").get())
782
+ db.prepare('INSERT INTO audit_log (ts, actor, action, target, ip) VALUES (?, ?, ?, ?, ?)').run(Date.now(), 'CLI', 'cli.reset', u.username, null);
783
+ return pw;
784
+ },
785
+ };
786
+ }
787
+
788
+ module.exports = { setupAuth, adminOps, _test: { hashPassword, hashPasswordSync, verifyPassword, totpAt, totpCheck, base32Encode, base32Decode, newRecoveryCodes, normRecovery } };