tacilytics 2.1.0 → 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/.env.example CHANGED
@@ -27,3 +27,8 @@ API_KEY=change-me
27
27
 
28
28
  # Data minimisation: auto-purge raw events older than N days (0 = keep forever)
29
29
  DATA_RETENTION_DAYS=0
30
+
31
+ # Weekly report by e-mail (optional). smtps://…:465 or smtp://…:587 (STARTTLS)
32
+ # SMTP_URL=smtps://user:password@mail.example.com:465
33
+ # REPORT_FROM=Tacilytics <reports@example.com>
34
+ # REPORT_HOUR_UTC=6
package/README.md CHANGED
@@ -111,9 +111,12 @@ Opt-in flags on the `<script>` tag:
111
111
  <script defer data-domain="example.com"
112
112
  data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
113
113
  data-track-outbound <!-- auto-record clicks to other domains as events -->
114
+ data-track-downloads <!-- auto-record file downloads (pdf, zip, docx …) as "File Download"; or ="pdf,epub" -->
114
115
  src="https://analytics.example.com/tacilytics.js"></script>
115
116
  ```
116
117
 
118
+ **Your own visits:** open your site once with `?tacilytics=off` (e.g. `https://example.com/?tacilytics=off`) and that browser is no longer counted on that site. `?tacilytics=on` undoes it. It is stored in the browser's localStorage, nothing is sent; repeat it on every browser and phone you use. Settings → Security has the links for all your sites.
119
+
117
120
  ## Mobile & desktop apps
118
121
 
119
122
  Track your **native app in the same dashboard as your website**. Every row gets a platform
@@ -152,6 +155,52 @@ The server hashes the launch id with a random daily salt that is deleted when th
152
155
  Without `sid`, the usual IP + user-agent daily hash is used. Region comes from the timezone or
153
156
  device locale, never from IP geolocation.
154
157
 
158
+ ## Import from Google Analytics or Plausible
159
+
160
+ **Import** (toolbar) brings your history along, so switching doesn't mean starting at zero.
161
+
162
+ - **Plausible:** Site settings → Imports & Exports → *Export data*. Upload the ZIP (or the CSVs in it).
163
+ - **Google Analytics 4:** open a report (*Pages and screens*, *Traffic acquisition*, *Demographic details*, *Tech*), add **Date** as a secondary dimension, *Share → Download file → CSV*. One file per report; English or German column names.
164
+
165
+ Imported numbers are daily totals per dimension (pages, sources, countries, browsers, OS, devices, events) and count **only for days before your own data starts** — never twice. They appear in the chart, the totals and the panels; while a filter is active they are left out (they can't be combined), and the dashboard says so. Every import can be removed again. No visitor-level data is imported or stored.
166
+
167
+ ## Notes in the chart
168
+
169
+ Click **Notes** in the chart legend to mark a day — a newsletter, a release, a sale. A coral line and a diamond appear on that day, the note shows in the tooltip, and everyone with access to the site (including share links) sees it. Admins and owners add and remove notes. `GET /api/annotations?site=…` lists them; `/stats` returns the ones in its range as `annotations`.
170
+
171
+ ## Time zone per site
172
+
173
+ Every site has its own time zone (any IANA name, e.g. `Europe/Berlin`; the browser's zone is preselected when you add a site, existing sites stay on `UTC`). Click the clock next to the site picker to change it. Days, the hourly view, date ranges, notes, the weekly report, the daily digest and the alert baseline all follow it, with daylight-saving days as 23 or 25 hours and offsets like +5:45 kept exact. Stored data is not touched, so switching back and forth is safe.
174
+
175
+ One limit: the anonymous visitor ID rotates at midnight **UTC** (that is what keeps it unlinkable across days). Outside UTC a local day therefore spans two IDs, and *unique visitors per day* are an approximation — pageviews, goals and revenue are exact.
176
+
177
+ `PATCH /api/sites/:id {"timezone":"Europe/Berlin"}` (admin) changes it via the API; unknown names are rejected with 400.
178
+
179
+ ## Weekly report by e-mail
180
+
181
+ Settings → Security → **Weekly report**: enter an address and tick *every Monday*. Each person gets one mail per week for **the sites they can see** — visitors and pageviews vs. the week before, top pages, sources, goals and a link to the dashboard. *Send one now* sends a sample.
182
+
183
+ Needs mail settings in `.env`:
184
+
185
+ ```env
186
+ SMTP_URL=smtps://user:password@mail.example.com:465 # or smtp://…:587 (STARTTLS)
187
+ REPORT_FROM=Tacilytics <reports@example.com>
188
+ REPORT_HOUR_UTC=6 # Monday, this hour (UTC)
189
+ TACILYTICS_ORIGIN=https://analytics.example.com # for the dashboard link in the mail
190
+ ```
191
+
192
+ ## Alerts
193
+
194
+ **Alerts** (toolbar) sends a message to a webhook — Discord, Slack, or anything that takes JSON — when:
195
+
196
+ - **Traffic spike:** about 3× the usual for this hour of the day,
197
+ - **Traffic drop:** under 20 % of usual (often: the tracker fell off the site),
198
+ - **Goal reached:** e.g. 10× `Purchase` within 1 hour, 24 hours or 7 days.
199
+
200
+ "Usual" is the median of the same hour over the last 14 days, so quiet nights don't look like incidents. Checked every 10 minutes, with a cooldown so one long spike is one message. **Test** sends a sample message. Admins and owners manage alerts.
201
+
202
+ Webhooks entered in the dashboard must be public `https://` addresses — no localhost, private ranges or redirects, so nobody on the team can use alerts to reach internal services. `DIGEST_WEBHOOK_URL` from `.env` is the default target (and may be internal); sites without own alerts keep the default spike/drop alerts there.
203
+
155
204
  ## Live visitors
156
205
 
157
206
  Click **“● online now”** for a realtime view: a 30-minute pulse and a live feed of
@@ -210,6 +259,10 @@ counts unique visitors *per day* and can’t follow the same person across days
210
259
  devices. You get the 95 % that matters (how many, which pages, from where) with 0 %
211
260
  cookie banner — the same approach as Plausible/Fathom.
212
261
 
262
+ ## Light mode & German
263
+
264
+ Settings → Security → **Appearance**: *Theme* (System / Light / Dark) and *Language* (Automatic / English / Deutsch), saved per browser. *System* follows the device's light/dark setting live; *Automatic* picks German when the browser is German. Your data (paths, sources, event names) is never translated.
265
+
213
266
  ## Team, 2FA & passkeys
214
267
 
215
268
  Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`). When you want more:
@@ -228,9 +281,15 @@ Everyone can turn on, **optionally**:
228
281
  - **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
229
282
  - **One-time codes (TOTP)** from any authenticator app, with 10 single-use **recovery codes**. Codes cannot be reused.
230
283
 
284
+ **Forgot the password?** An owner clicks **Password link** next to the person in Settings → Team: a one-time link (24 h) to set a new one. Their 2FA stays on and all their sessions end.
285
+
286
+ **Activity log:** owners see every change in Settings → Activity — who added a site, invited someone, changed a role, created an API key, turned 2FA off … with time and address. Failed attempts and sign-ins are not logged, secrets never. Kept for a year (`AUDIT_RETENTION_DAYS`, minimum 30).
287
+
231
288
  **Locked out?** On the server: `tacilytics users` lists accounts, `tacilytics reset-login <username>` prints a new password, turns 2FA off and ends that person's sessions. An owner can also reset a teammate's 2FA in Settings → Team.
232
289
 
233
- The API key (`API_KEY`, Bearer) keeps owner rights for agents and the CLI.
290
+ **API keys per person:** Settings → Security → API keys creates a personal key (`tk_…`) for scripts, agents and the CLI. It acts as you and never more: it sees only your sites, is **read-only unless you untick it** (a viewer's key always is), and cannot manage the team or change security settings. Keys are stored as SHA-256, shown once, and can be revoked; removing a member revokes theirs.
291
+
292
+ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI on your own box.
234
293
 
235
294
  ## Configuration
236
295
 
@@ -239,10 +298,15 @@ The API key (`API_KEY`, Bearer) keeps owner rights for agents and the CLI.
239
298
  | `PORT` | `3920` | Port (bound to 127.0.0.1) |
240
299
  | `WEB_PASSWORD` | `tacilytics` | Dashboard passphrase — **only until the first account exists** |
241
300
  | `TACILYTICS_ORIGIN` | request host | Public URL of the dashboard, e.g. `https://analytics.example.com`. Pins the passkey domain; recommended behind a proxy |
242
- | `TRUST_PROXY` | `1` | Number of reverse proxies in front of Tacilytics (e.g. Cloudflare + Caddy = `2`). Rate limits **and visitor hashes** use the address the outermost trusted proxy saw, never the client-written first `X-Forwarded-For` entry. Too low: with two proxies and `1`, every visitor looks like the Cloudflare address and unique visitors collapse. Too high: clients can pick their own address and bypass the limits. Tacilytics binds to 127.0.0.1, so a proxy is always in front and `0` is almost always wrong (everyone becomes 127.0.0.1) |
301
+ | `TRUST_PROXY` | `1` | Number of HTTP reverse proxies in front of Tacilytics that append `X-Forwarded-For` (e.g. Cloudflare + Caddy = `2`; a plain TCP forwarder like socat or `ssh -L` does not count). Rate limits **and visitor hashes** use the address the outermost trusted proxy saw, never the client-written first `X-Forwarded-For` entry. Too low: with two proxies and `1`, every visitor looks like the Cloudflare address and unique visitors collapse. Too high: clients can pick their own address and bypass the limits. Tacilytics binds to 127.0.0.1, so a proxy is always in front and `0` is almost always wrong (everyone becomes 127.0.0.1). A request without `X-Forwarded-For` logs a one-time warning |
243
302
  | `INGEST_SALT` | fixed | Extra server-side secret mixed into the visitor hash (on top of the random daily salt) |
244
303
  | `API_KEY` | — | Bearer key for the API / CLI |
245
304
  | `DB_PATH` | `./data/analytics.db` | SQLite path |
305
+ | `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
306
+ | `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
307
+ | `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
308
+ | `REPORT_HOUR_UTC` | `6` | Monday hour (UTC) the weekly report goes out; it covers the last 7 full days in each site's time zone |
309
+ | `AUDIT_RETENTION_DAYS` | `365` | How long the team activity log is kept (min. 30) |
246
310
  | `DATA_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` purges raw events older than N days, daily (data minimisation) |
247
311
 
248
312
  ## Privacy & security
package/auth.js CHANGED
@@ -15,10 +15,12 @@
15
15
  * TOTP secrets are stored as-is (they must be readable to verify codes).
16
16
  */
17
17
  const crypto = require('crypto');
18
+ const PKG_VERSION = require('./package.json').version;
18
19
 
19
20
  const ROLES = { viewer: 1, admin: 2, owner: 3 };
20
21
  const SESSION_DAYS = 30;
21
22
  const INVITE_DAYS = 7;
23
+ const RESET_HOURS = 24;
22
24
  const MIN_PW = 10;
23
25
 
24
26
  // ── small crypto helpers ───────────────────────────────────────────────────
@@ -138,6 +140,33 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
138
140
  created INTEGER NOT NULL,
139
141
  expires INTEGER NOT NULL
140
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
+ );
141
170
  CREATE TABLE IF NOT EXISTS invites (
142
171
  id INTEGER PRIMARY KEY AUTOINCREMENT,
143
172
  token_hash TEXT NOT NULL UNIQUE,
@@ -169,6 +198,39 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
169
198
  pkGet: db.prepare('SELECT * FROM passkeys WHERE id = ?'),
170
199
  };
171
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 } : {}) }; };
172
234
  if (hasUsers() && !process.env.TACILYTICS_ORIGIN) {
173
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.');
174
236
  }
@@ -183,10 +245,21 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
183
245
  }
184
246
  function principal(req) {
185
247
  // agents / CLI: API key = owner over everything
186
- if (API_KEY) {
187
- const hdr = String(req.headers['authorization'] || '');
188
- const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
189
- if (key && safeEq(key, API_KEY)) return { kind: 'apikey', role: 'owner', sites: null, user: null };
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
190
263
  }
191
264
  const t = String(req.headers['x-session-token'] || '');
192
265
  if (!t) return null;
@@ -405,13 +478,16 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
405
478
  const uid = tx();
406
479
  if (uid === 'taken') return res.status(409).json({ error: 'This username is taken' });
407
480
  if (!uid) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
481
+ audit(res, 'team.join', name, name);
408
482
  res.json({ token: createSession(uid) });
409
483
  });
410
484
 
411
485
  // ── me ─────────────────────────────────────────────────────────────────────
412
486
  app.get('/api/me', auth, (req, res) => {
413
487
  const p = req.principal;
414
- const out = { kind: p.kind, role: p.role, accounts: hasUsers() };
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 };
415
491
  if (p.user) {
416
492
  const u = q.userById.get(p.user.id);
417
493
  Object.assign(out, publicUser(u), {
@@ -421,7 +497,10 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
421
497
  }
422
498
  res.json(out);
423
499
  });
424
- const userOnly = (req, res, next) => (req.principal.user ? next() : res.status(400).json({ error: 'Create an account first' }));
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
+ };
425
504
  async function needPassword(req, res) {
426
505
  if (rateLimited('reauth:' + req.principal.user.id, 10, 15 * 60000)) { res.status(429).json({ error: 'Too many attempts — wait 15 minutes' }); return false; }
427
506
  const u = q.userById.get(req.principal.user.id);
@@ -435,6 +514,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
435
514
  if (npw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
436
515
  db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(await hashPassword(npw), req.principal.user.id);
437
516
  q.sessDelUserExcept.run(req.principal.user.id, req.principal.tokenHash); // sign out other devices
517
+ audit(res, 'me.password');
438
518
  res.json({ ok: true });
439
519
  });
440
520
 
@@ -460,11 +540,13 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
460
540
  db.prepare('UPDATE users SET totp_secret = totp_pending, totp_pending = NULL, totp_last_step = ?, recovery = ? WHERE id = ?')
461
541
  .run(step, JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
462
542
  q.sessDelUserExcept.run(u.id, req.principal.tokenHash);
543
+ audit(res, 'me.2fa.on');
463
544
  res.json({ ok: true, recoveryCodes: codes });
464
545
  });
465
546
  app.post('/api/me/totp/disable', auth, userOnly, async (req, res) => {
466
547
  if (!(await needPassword(req, res))) return;
467
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');
468
550
  res.json({ ok: true });
469
551
  });
470
552
  app.post('/api/me/recovery', auth, userOnly, async (req, res) => {
@@ -473,6 +555,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
473
555
  if (!u.totp_secret) return res.status(400).json({ error: '2FA is off' });
474
556
  const codes = newRecoveryCodes();
475
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');
476
559
  res.json({ recoveryCodes: codes });
477
560
  });
478
561
 
@@ -505,15 +588,53 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
505
588
  const name = String(b.name || '').trim().slice(0, 60) || 'Passkey';
506
589
  db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
507
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);
508
592
  res.json({ ok: true });
509
593
  });
510
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);
511
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);
512
630
  res.json({ ok: true });
513
631
  });
514
632
 
515
633
  // ── team (owner) ───────────────────────────────────────────────────────────
516
- const ownerOnly = requireRole('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()));
517
638
 
518
639
  // first account: only from a legacy (WEB_PASSWORD) or API-key session, only while there are none
519
640
  app.post('/api/team/bootstrap', ownerOnly, async (req, res) => {
@@ -524,6 +645,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
524
645
  if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
525
646
  const info = q.insUser.run(name, await hashPassword(pw), 'owner', null, b64u(crypto.randomBytes(16)), Date.now());
526
647
  q.sessDelLegacy.run(); // WEB_PASSWORD sessions end here
648
+ audit(res, 'team.first-owner', name);
527
649
  res.json({ token: createSession(info.lastInsertRowid) });
528
650
  });
529
651
 
@@ -545,10 +667,12 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
545
667
  const expires = Date.now() + INVITE_DAYS * 864e5;
546
668
  db.prepare('INSERT INTO invites (token_hash, role, sites, created_by, created, expires) VALUES (?, ?, ?, ?, ?, ?)')
547
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(',') : ''));
548
671
  res.json({ url: '/invite/' + token, role, expires });
549
672
  });
550
673
  app.delete('/api/team/invites/:id', ownerOnly, (req, res) => {
551
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));
552
676
  res.json({ ok: true });
553
677
  });
554
678
  app.patch('/api/team/users/:id', ownerOnly, (req, res) => {
@@ -562,6 +686,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
562
686
  if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
563
687
  if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
564
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(',') : ''));
565
690
  res.json(publicUser(q.userById.get(u.id)));
566
691
  });
567
692
  app.delete('/api/team/users/:id', ownerOnly, (req, res) => {
@@ -570,9 +695,12 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
570
695
  if (u.role === 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
571
696
  db.transaction(() => {
572
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);
573
700
  q.sessDelUser.run(u.id);
574
701
  db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
575
702
  })();
703
+ audit(res, 'team.remove', u.username);
576
704
  res.json({ ok: true });
577
705
  });
578
706
  // owner can reset 2FA of a teammate who lost their phone (the teammate then sets it up again)
@@ -581,10 +709,59 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
581
709
  if (!u) return res.status(404).json({ error: 'Unknown user' });
582
710
  db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(u.id);
583
711
  q.sessDelUser.run(u.id);
712
+ audit(res, 'team.2fa.reset', u.username);
584
713
  res.json({ ok: true });
585
714
  });
586
715
 
587
- return { auth, requireRole, siteAllowed, principal };
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 };
588
765
  }
589
766
 
590
767
  // ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
@@ -600,6 +777,9 @@ function adminOps(db) {
600
777
  const pw = crypto.randomBytes(12).toString('base64url');
601
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);
602
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);
603
783
  return pw;
604
784
  },
605
785
  };