tacilytics 2.2.0 → 2.3.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/README.md CHANGED
@@ -273,9 +273,11 @@ Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`).
273
273
  | Role | Can |
274
274
  |---|---|
275
275
  | **Owner** | everything, including the team |
276
- | **Admin** | add/remove sites, share links, funnels |
276
+ | **Admin** | add/remove sites, share links, funnels — optionally limited to **specific sites** |
277
277
  | **Viewer** | read only — optionally limited to **specific sites** |
278
278
 
279
+ **Hosting several customers on one instance** (2.3): give each customer an **admin limited to their sites**. The operator adds their sites; a domain that already belongs to someone else is refused (409) instead of being shared. Pause a site with `PATCH /api/sites/:id {"paused": true}` (server key or an unlimited owner/admin): it collects nothing until resumed, the data stays. `GET /api/v1/usage?month=YYYY-MM` lists page views per site for volume checks (operator only). The operator's `DIGEST_WEBHOOK_URL` is never used for alerts of limited accounts; set `DIGEST_WEBHOOK_URL=off` on a shared instance so the daily digest does not list customer domains. `DASHBOARD_EXTRA_SCRIPT=/path.js` adds one same-origin script to the dashboard (e.g. a billing banner served by your own proxy). Paused sites are left out of alerts and the weekly report. Site-limited accounts cannot add or delete sites (the operator does both, sites are what is billed and paused). **Domains are first come, first served:** if a customer gets 409 for a domain they own, the operator resolves it with an owner account (move the site to the right account in Settings → Team); domain verification is planned.
280
+
279
281
  Everyone can turn on, **optionally**:
280
282
 
281
283
  - **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
@@ -303,6 +305,7 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
303
305
  | `API_KEY` | — | Bearer key for the API / CLI |
304
306
  | `DB_PATH` | `./data/analytics.db` | SQLite path |
305
307
  | `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
308
+ | `DASHBOARD_EXTRA_SCRIPT` | — | One same-origin script added to the dashboard page (hosting banner). Only a plain path like `/cloud/ui.js`; URLs, `//` and `..` are ignored |
306
309
  | `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
307
310
  | `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
308
311
  | `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 |
package/auth.js CHANGED
@@ -660,7 +660,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
660
660
  const b = req.body || {};
661
661
  const role = cleanRole(b.role);
662
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;
663
+ const sites = role !== 'owner' ? cleanSites(b.sites) : null; // admins and viewers can be limited to sites
664
664
  if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
665
665
  if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
666
666
  const token = randToken();
@@ -682,7 +682,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
682
682
  const role = b.role === undefined ? u.role : cleanRole(b.role);
683
683
  if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
684
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;
685
+ let sites = role !== 'owner' ? (b.sites === undefined ? (u.sites ? JSON.parse(u.sites) : null) : cleanSites(b.sites)) : null;
686
686
  if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
687
687
  if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
688
688
  db.prepare('UPDATE users SET role = ?, sites = ? WHERE id = ?').run(role, sites ? JSON.stringify(sites) : null, u.id);
@@ -761,7 +761,20 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
761
761
  res.json({ ok: true, username: who });
762
762
  });
763
763
 
764
- return { auth, requireRole, siteAllowed, principal, audit };
764
+ /** a site created by a site-limited account becomes part of that account's list */
765
+ function grantSite(req, siteId) {
766
+ const p = req.principal;
767
+ if (!p || p.sites == null || !p.user) return;
768
+ const u = q.userById.get(p.user.id);
769
+ if (!u || u.sites == null) return;
770
+ const list = JSON.parse(u.sites);
771
+ if (!list.includes(siteId)) list.push(siteId);
772
+ db.prepare('UPDATE users SET sites = ? WHERE id = ?').run(JSON.stringify(list), u.id);
773
+ p.sites = list;
774
+ }
775
+ /** limited = can only see some sites (cloud customers, viewers with a site list) */
776
+ const isLimited = (req) => !!(req.principal && req.principal.sites != null);
777
+ return { auth, requireRole, siteAllowed, principal, audit, grantSite, isLimited };
765
778
  }
766
779
 
767
780
  // ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tacilytics",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
package/public/account.js CHANGED
@@ -163,7 +163,7 @@
163
163
 
164
164
  // ── settings ──
165
165
  var ROLE_LABEL = { owner: 'Owner', admin: 'Admin', viewer: 'Viewer' };
166
- var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', viewer: 'read only' };
166
+ var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels (all or picked sites)', viewer: 'read only' };
167
167
  var me = null;
168
168
  function setErr(t) { $('setErr').textContent = t || ''; }
169
169
  // footer version: only after sign-in (from /api/me), links to the release tag; strict format check so
@@ -183,6 +183,7 @@
183
183
  $('meName').textContent = m.username || 'Settings';
184
184
  showVersion(m.version);
185
185
  document.body.classList.toggle('role-viewer', m.role === 'viewer');
186
+ document.body.classList.toggle('site-limited', !!(m.sites && m.role !== 'owner')); // the operator adds their sites
186
187
  }).catch(function () {});
187
188
  }
188
189
  function openSettings(tab) {
@@ -418,15 +419,15 @@
418
419
  var self = me && me.id === u.id;
419
420
  return '<div class="set-row team-row" data-uid="' + u.id + '">' +
420
421
  '<span><strong data-no-i18n>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
421
- '<br><span class="muted small">' + (u.role === 'viewer' ? siteNames(u.sites) + ' · ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' · ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' · last sign-in ' + date(u.last_login) + '</span></span>' +
422
+ '<br><span class="muted small">' + (u.role !== 'owner' ? siteNames(u.sites) + ' · ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' · ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' · last sign-in ' + date(u.last_login) + '</span></span>' +
422
423
  '<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
423
- '<span class="row-actions">' + (u.role === 'viewer' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
424
+ '<span class="row-actions">' + (u.role !== 'owner' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
424
425
  (!self ? '<button class="btn ghost mini" data-resetlink title="One-time link to set a new password (24 h)">Password link</button>' : '') +
425
426
  (u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
426
427
  (!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
427
428
  }).join('');
428
429
  var invites = t.invites.map(function (i) {
429
- return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role === 'viewer' ? ' · ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
430
+ return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role !== 'owner' ? ' · ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
430
431
  }).join('');
431
432
  el.innerHTML =
432
433
  '<div class="set-card"><h3>' + ico('i-users') + 'Members</h3>' + rows + '</div>' +
@@ -436,11 +437,11 @@
436
437
  '<div id="invSites">' + sitePicker('invPick', null) + '</div>' +
437
438
  '<div id="invOut"></div>' + (invites ? '<h4 class="muted small" style="margin-top:12px">Open invites</h4>' + invites : '') + '</div>';
438
439
 
439
- $('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value !== 'viewer'); };
440
+ $('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value === 'owner'); };
440
441
  $('invCreate').onclick = function () {
441
442
  setErr('');
442
443
  var role = $('invRole').value;
443
- call('POST', '/api/team/invites', { role: role, sites: role === 'viewer' ? pickedSites('invPick') : null }).then(function (r) {
444
+ call('POST', '/api/team/invites', { role: role, sites: role !== 'owner' ? pickedSites('invPick') : null }).then(function (r) {
444
445
  var url = location.origin + r.url;
445
446
  $('invOut').innerHTML = '<div class="snippet" id="invUrl">' + esc(url) + '</div><button class="btn ghost" id="invCopy">Copy link</button>';
446
447
  $('invCopy').onclick = function () { navigator.clipboard.writeText(url); $('invCopy').textContent = 'Copied ✓'; };
package/public/i18n.js CHANGED
@@ -142,7 +142,7 @@
142
142
  'For scripts, agents and the CLI (': 'Für Skripte, Agents und die CLI (',
143
143
  '). A key acts as you, sees only your sites, and cannot touch the team or your security settings. Read-only unless you untick it.': '). Ein Key handelt als du, sieht nur deine Seiten und kann weder das Team noch deine Sicherheitseinstellungen ändern. Nur lesen, außer du nimmst den Haken raus.',
144
144
  'Signed in as': 'Angemeldet als',
145
- 'Viewer — read only': 'Viewer: nur lesen', 'Admin — sites, share links and funnels': 'Admin: Seiten, Share-Links und Funnels', 'Owner — everything, including the team': 'Owner: alles, auch das Team',
145
+ 'Viewer — read only': 'Viewer: nur lesen', 'Admin — sites, share links and funnels (all or picked sites)': 'Admin: Seiten, Share-Links und Funnels (alle oder ausgewählte Seiten)', 'Owner — everything, including the team': 'Owner: alles, auch das Team',
146
146
  'In Plausible:': 'In Plausible:', 'Site settings → Imports & Exports → Export data': 'Website-Einstellungen → Imports & Exports → Export data',
147
147
  '. Upload the ZIP you get (or the CSV files in it).': '. Lade das ZIP hoch (oder die CSV-Dateien darin).',
148
148
  'In Google Analytics 4: open a report (e.g.': 'In Google Analytics 4: Öffne einen Bericht (z. B.', 'Pages and screens': 'Seiten und Bildschirme', 'Traffic acquisition': 'Akquisition von Zugriffen',
package/public/style.css CHANGED
@@ -451,6 +451,7 @@ select option { background: #0c1a1d; color: var(--text); }
451
451
  #login .btn.ghost .ico, #settingsBtn .ico { margin-right: 6px; }
452
452
  /* viewers: hide everything that would only answer with 403 */
453
453
  body.role-viewer #addSiteBtn, body.role-viewer #shareBtn, body.role-viewer #addFunnelBtn { display: none !important; }
454
+ body.site-limited #addSiteBtn { display: none !important; }
454
455
  @media (max-width: 560px) { #meName { display: none; } .team-row { flex-wrap: wrap; } }
455
456
  a.btn, a.btn:hover { text-decoration: none; display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
456
457
  .swatch.ann { width: 9px; height: 9px; transform: rotate(45deg); border-radius: 1px; background: var(--accent-2, #ff7a5c); }
package/server.js CHANGED
@@ -143,6 +143,8 @@ db.exec(`
143
143
  const norm = (d) => String(d || '').toLowerCase().replace(/^www\./, '').replace(/\/.*$/, '').trim();
144
144
  // per-site time zone (Fabian): only query boundaries move, stored days stay UTC (Tyto). Old installs → 'UTC'.
145
145
  if (!db.prepare('PRAGMA table_info(sites)').all().some((c) => c.name === 'timezone')) db.exec("ALTER TABLE sites ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'");
146
+ // paused sites collect nothing (hosting: trial over / subscription ended). Only an unlimited admin or owner can pause.
147
+ if (!db.prepare('PRAGMA table_info(sites)').all().some((c) => c.name === 'paused')) db.exec('ALTER TABLE sites ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
146
148
  const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
147
149
  const insSite = db.prepare("INSERT OR IGNORE INTO sites (domain, name, created, timezone) VALUES (?, ?, ?, COALESCE(?, 'UTC'))");
148
150
  const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
@@ -317,7 +319,7 @@ function collect(req, res) {
317
319
  const domain = norm(b.d || b.domain);
318
320
  if (!domain) return res.status(204).end();
319
321
  const site = getSiteByDomain.get(domain);
320
- if (!site) return res.status(202).end(); // unknown site → silently ignore
322
+ if (!site || site.paused) return res.status(202).end(); // unknown or paused site → silently ignore
321
323
  const platform = appPlatform(b);
322
324
  // apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
323
325
  // A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
@@ -386,7 +388,7 @@ app.get('/api/collect', collect);
386
388
 
387
389
  // ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
388
390
  const { setupAuth } = require('./auth');
389
- const { auth, requireRole, siteAllowed, audit } = setupAuth({
391
+ const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAuth({
390
392
  app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
391
393
  allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
392
394
  });
@@ -411,6 +413,9 @@ function newToken() { return crypto.randomBytes(24).toString('hex'); }
411
413
  // ── sites management ─────────────────────────────────────────────────────────
412
414
  const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
413
415
  const sitesCreateHandler = (req, res) => {
416
+ // a site-limited account (a hosting customer) never creates sites itself: the operator does, because sites are what
417
+ // is billed and paused. Otherwise a paused customer could add a fresh, unpaused site and move the snippet (Kiro, 2.3.0).
418
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
414
419
  const rawDomain = norm(req.body && req.body.domain);
415
420
  let domain = rawDomain;
416
421
  let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
@@ -423,7 +428,17 @@ const sitesCreateHandler = (req, res) => {
423
428
  const rawTz = req.body && req.body.timezone;
424
429
  const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
425
430
  if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
426
- insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
431
+ const existing = getSiteByDomain.get(domain);
432
+ if (existing) {
433
+ // an account limited to some sites must never get a foreign site back (or added to its list) by naming its domain
434
+ if (!siteAllowed(req, existing.id)) return res.status(409).json({ error: 'This domain is already registered' });
435
+ return res.json(existing);
436
+ }
437
+ const info = insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
438
+ // defensive: if INSERT OR IGNORE did nothing (e.g. a second process sharing the DB won the domain), lastInsertRowid is
439
+ // stale and must never be granted. Not reachable over HTTP in one process (the handler is synchronous), so no test.
440
+ if (!info.changes) return res.status(409).json({ error: 'This domain is already registered' });
441
+ grantSite(req, Number(info.lastInsertRowid));
427
442
  audit(res, 'site.add', domain);
428
443
  res.json(getSiteByDomain.get(domain));
429
444
  };
@@ -435,15 +450,27 @@ app.patch('/api/sites/:id', adminOnly, (req, res) => {
435
450
  if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
436
451
  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
437
452
  if (!site) return res.status(404).json({ error: 'Unknown site' });
438
- const timezone = TZ.validTz(String((req.body && req.body.timezone) || ''));
439
- if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
440
- db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
441
- if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
453
+ const b = req.body || {};
454
+ if (b.paused !== undefined) {
455
+ // pausing is the operator's switch (hosting: trial or subscription ended), never the customer's
456
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
457
+ const paused = b.paused ? 1 : 0;
458
+ db.prepare('UPDATE sites SET paused = ? WHERE id = ?').run(paused, id);
459
+ if (paused !== site.paused) audit(res, paused ? 'site.pause' : 'site.resume', site.domain);
460
+ }
461
+ if (b.timezone !== undefined || b.paused === undefined) {
462
+ const timezone = TZ.validTz(String(b.timezone || ''));
463
+ if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
464
+ db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
465
+ if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
466
+ }
442
467
  res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
443
468
  });
444
469
  app.delete('/api/sites/:id', adminOnly, (req, res) => {
445
470
  const id = Number(req.params.id);
446
471
  if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
472
+ // hard delete frees the domain and resets usage: only the operator (unlimited admin/owner) may do it (Tyto/Kiro, 2.3.0)
473
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
447
474
  const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
448
475
  audit(res, 'site.remove', gone ? gone.domain : '#' + id);
449
476
  db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
@@ -464,10 +491,20 @@ app.get('/api/v1', (_req, res) => res.json({
464
491
  'POST /api/v1/sites': 'add a site { domain }',
465
492
  'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
466
493
  'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
494
+ 'GET /api/v1/usage?month=YYYY-MM': 'page views per site in a calendar month (UTC), owner / server key only',
467
495
  },
468
496
  filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
469
497
  }));
470
498
  app.get('/api/v1/sites', auth, sitesListHandler);
499
+ // page views per site in one calendar month (UTC), for hosting volume checks. Operator only:
500
+ // owners or the server API key with access to all sites, never a site-limited account.
501
+ app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
502
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
503
+ const m = String(req.query.month || new Date().toISOString().slice(0, 7));
504
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(m)) return res.status(400).json({ error: 'month must be YYYY-MM' });
505
+ const from = Date.UTC(+m.slice(0, 4), +m.slice(5, 7) - 1, 1), to = Date.UTC(+m.slice(0, 4), +m.slice(5, 7), 1);
506
+ res.json(db.prepare('SELECT s.id site_id, s.domain, COUNT(e.id) pageviews FROM sites s LEFT JOIN events e ON e.site_id = s.id AND e.ts >= ? AND e.ts < ? AND e.name IS NULL GROUP BY s.id ORDER BY s.id').all(from, to));
507
+ });
471
508
  app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
472
509
 
473
510
  // ── stats ────────────────────────────────────────────────────────────────────
@@ -983,6 +1020,16 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
983
1020
  });
984
1021
 
985
1022
  // ── static dashboard ─────────────────────────────────────────────────────────
1023
+ // Hosting (e.g. Tacilytics Cloud): DASHBOARD_EXTRA_SCRIPT="/cloud/ui.js" adds one same-origin script to the
1024
+ // dashboard page (trial/billing banner). Only a plain local path is accepted, never a URL to another host.
1025
+ const EXTRA_SCRIPT = /^\/[A-Za-z0-9._\/-]{1,120}$/.test(process.env.DASHBOARD_EXTRA_SCRIPT || '') && !/\.\.|\/\//.test(process.env.DASHBOARD_EXTRA_SCRIPT) ? process.env.DASHBOARD_EXTRA_SCRIPT : '';
1026
+ const INDEX_HTML = path.join(__dirname, 'public', 'index.html');
1027
+ function sendIndex(_req, res) {
1028
+ if (!EXTRA_SCRIPT) return res.sendFile(INDEX_HTML);
1029
+ const html = fs.readFileSync(INDEX_HTML, 'utf8').replace('</body>', `<script src="${EXTRA_SCRIPT}" defer></script>\n</body>`);
1030
+ res.type('html').set('Cache-Control', 'no-cache').send(html);
1031
+ }
1032
+ app.get(['/', '/index.html'], sendIndex);
986
1033
  app.use(express.static(path.join(__dirname, 'public')));
987
1034
  // ── visitor journeys: session-based path transitions for a sankey flow ───────────
988
1035
  // A "session" = one visitor's pageviews with < 30 min between them.
@@ -1093,7 +1140,7 @@ app.get('/api/alerts', auth, (req, res) => {
1093
1140
  const site = getSiteByDomain.get(norm(req.query.site));
1094
1141
  if (!site) return res.status(404).json({ error: 'Unknown site' });
1095
1142
  if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
1096
- res.json({ fallbackWebhook: !!DIGEST_WEBHOOK_URL, alerts: db.prepare('SELECT * FROM alerts WHERE site_id = ? ORDER BY id').all(site.id).map((a) => publicAlert(a, site.domain)) });
1143
+ res.json({ fallbackWebhook: !!DIGEST_WEBHOOK_URL && !isLimited(req), alerts: db.prepare('SELECT * FROM alerts WHERE site_id = ? ORDER BY id').all(site.id).map((a) => publicAlert(a, site.domain)) });
1097
1144
  });
1098
1145
  app.post('/api/alerts', adminOnly, async (req, res) => {
1099
1146
  const b = req.body || {};
@@ -1118,7 +1165,10 @@ app.post('/api/alerts', adminOnly, async (req, res) => {
1118
1165
  if (prob) return res.status(400).json({ error: prob });
1119
1166
  if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
1120
1167
  webhook = String(b.webhook);
1121
- } else if (!DIGEST_WEBHOOK_URL) return res.status(400).json({ error: 'Add a webhook URL (no default DIGEST_WEBHOOK_URL is set)' });
1168
+ } else if (!DIGEST_WEBHOOK_URL || isLimited(req)) {
1169
+ // the operator's webhook is for the operator: an account limited to some sites must bring its own
1170
+ return res.status(400).json({ error: 'Add a webhook URL' + (DIGEST_WEBHOOK_URL ? '' : ' (no default DIGEST_WEBHOOK_URL is set)') });
1171
+ }
1122
1172
  if (db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n >= 20) return res.status(400).json({ error: 'At most 20 alerts per site' });
1123
1173
  const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
1124
1174
  .run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
@@ -1173,7 +1223,7 @@ const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/
1173
1223
  const { createReport, REPORT_LANGS } = require('./report');
1174
1224
  const reporter = createReport(db);
1175
1225
  function buildReport(user, now) {
1176
- const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
1226
+ const sites = allSites.all().filter((st) => !st.paused && (!user.sites || JSON.parse(user.sites).includes(st.id)));
1177
1227
  return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
1178
1228
  }
1179
1229
  async function sendReport(user, now = Date.now()) {
@@ -1303,6 +1353,7 @@ const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
1303
1353
  async function alertTick(now = Date.now()) {
1304
1354
  const fired = [];
1305
1355
  for (const site of allSites.all()) {
1356
+ if (site.paused) continue; // a paused site collects nothing: a 'drop' alert would only announce the pause (Tyto, 2.3.0)
1306
1357
  const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
1307
1358
  const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
1308
1359
  let verdict = null;
@@ -1353,6 +1404,6 @@ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
1353
1404
  // unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
1354
1405
  // route it doesn't have must get an error it can show, not HTML it would try to read as data (Kiro, 2.2.0).
1355
1406
  app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
1356
- app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
1407
+ app.get('*', sendIndex);
1357
1408
 
1358
1409
  app.listen(PORT, '127.0.0.1', () => console.log(`Tacilytics on http://127.0.0.1:${PORT}${RETENTION_DAYS ? ` · retention ${RETENTION_DAYS}d` : ''}${DIGEST_WEBHOOK_URL ? ` · digest ${DIGEST_HOUR_UTC}:00 UTC` : ''}`));