tacilytics 2.2.0 → 2.4.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
@@ -5,7 +5,7 @@
5
5
  # Tacilytics
6
6
 
7
7
  **Privacy-first, cookieless analytics for websites and apps — self-hosted in minutes.**
8
- *Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no consent banner, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
8
+ *Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no IDs, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
9
9
 
10
10
  > Formerly **Nyx Analytics**. Existing installs keep working: `/nyx.js`, `window.nyx()` and the old Flutter class names are still supported as aliases.
11
11
 
@@ -22,7 +22,7 @@
22
22
 
23
23
  ## Why Tacilytics
24
24
 
25
- - 🍪 **Cookieless & banner-free.** No cookies, no `localStorage`, no fingerprinting — nothing to consent to.
25
+ - 🍪 **Cookieless.** No cookies, no IDs, nothing written to the visitor's device, no fingerprinting. **Strict mode** (`data-strict`) also reduces what the tracker reads on the device to what every page request carries anyway — see [Strict mode](#strict-mode).
26
26
  - 🛡️ **IPs are never stored.** Unique visitors are counted with a *daily, salted, one-way hash* that’s irreversible and rotated every day.
27
27
  - 🌍 **Region from the browser timezone**, never from IP geolocation.
28
28
  - 🔍 **Segment everything.** Click any country, page, source, browser or campaign to filter the whole dashboard — stacked, with one-click chips.
@@ -72,7 +72,7 @@ Open the dashboard, log in, click **+ Add site**, and drop the snippet into your
72
72
  <script defer data-domain="example.com" src="https://analytics.example.com/tacilytics.js"></script>
73
73
  ```
74
74
 
75
- That’s it. Pageviews start flowing in — no cookie banner required.
75
+ That’s it. Pageviews start flowing in, without cookies. (Want the tracker to read less on the device as well? Add `data-strict`, see [Strict mode](#strict-mode).)
76
76
 
77
77
  ## Custom events, goals & properties
78
78
 
@@ -110,11 +110,59 @@ Opt-in flags on the `<script>` tag:
110
110
  ```html
111
111
  <script defer data-domain="example.com"
112
112
  data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
113
+ data-strict <!-- reduce device reads (see Strict mode) -->
113
114
  data-track-outbound <!-- auto-record clicks to other domains as events -->
114
115
  data-track-downloads <!-- auto-record file downloads (pdf, zip, docx …) as "File Download"; or ="pdf,epub" -->
115
116
  src="https://analytics.example.com/tacilytics.js"></script>
116
117
  ```
117
118
 
119
+ ### Strict mode
120
+
121
+ The default tracker writes nothing to the visitor's device, but it does **read** a few things there: one `localStorage` flag (your own visits, below), the time zone (→ country), the browser language, the window size (→ scroll depth) and, with `data-honor-dnt`, the DNT/GPC setting. EU device-access rules (ePrivacy Art. 5(3), in Germany § 25 TDDDG) cover reading as well as storing, and the EDPB reads "access" broadly (Guidelines 2/2023 also count information a script makes the browser send). Strict mode reduces device access to what every page request carries anyway:
122
+
123
+ ```html
124
+ <script defer data-domain="example.com" data-strict data-honor-dnt src="https://analytics.example.com/tacilytics.js"></script>
125
+ ```
126
+
127
+ In strict mode the tracker does not read local storage, the time zone, the language, the window or screen size, or the DNT/GPC setting. It sends what the page knows: domain, path, referrer, UTM parameters and time on page; the browser adds its usual request headers. To get there it still uses page state, not device properties: `location` and the query string (UTM), `document.referrer`, the DOM for the optional 404 and site-search detection, and a check that `navigator.sendBeacon` exists. Some authorities (EDPB Guidelines 2/2023) read the rules more broadly than "device properties", so do not take "strict" as "nothing at all". The server takes the language from `Accept-Language`, and with `data-honor-dnt` it checks the `DNT` / `Sec-GPC` request headers. Two things are still used for timing and never sent: the page's visibility state (`document.visibilityState`) and the device clock (`Date.now()`, only differences, for time on page). A test runs the tracker with every other device API booby-trapped and fails if strict mode touches one.
128
+
129
+ **WordPress plugin:** its role exclusion (don't count logged-in admins, editors …) uses a `localStorage` marker in a small inline script before the tracker. Turn the role exclusion off when you use strict mode; a later plugin release will handle it on the server.
130
+
131
+ What you give up: **no country** (it came from the time zone), **no scroll depth** (time on page stays), and `?tacilytics=off` does not work (the console says so) — exclude your own visits another way (e.g. a test browser you don't count, or filter yourself out in the dashboard). Strict mode does not decide whether you need consent; that depends on your jurisdiction and the rest of your website. This is not legal advice.
132
+
133
+ ### Server mode (no script at all)
134
+
135
+ Server mode runs no script and reads or stores nothing on the visitor's device. Your web server reports each page view itself, from the request the visitor sent to your page anyway: path, `Referer`, `Accept-Language`, `User-Agent` and the visitor IP. Don't add the `<script>` tag on pages that use server mode; mixing both on one page would bring the script back.
136
+
137
+ ```http
138
+ POST /api/v1/hit
139
+ Authorization: Bearer tk_… # a key created with write access (Settings → API keys), not read-only
140
+ Content-Type: application/json
141
+
142
+ { "domain": "example.com", "path": "/shop?utm_source=news", "ip": "203.0.113.7",
143
+ "ua": "<User-Agent header>", "referrer": "<Referer header>", "lang": "<Accept-Language header>",
144
+ "purpose": "<Sec-Purpose or Purpose header>" }
145
+ ```
146
+
147
+ Node / Express, after your static files and before your pages:
148
+
149
+ ```js
150
+ app.use((req, res, next) => {
151
+ if (req.method === 'GET' && req.accepts('html')) fetch('https://analytics.example.com/api/v1/hit', {
152
+ method: 'POST', headers: { Authorization: 'Bearer ' + process.env.TACILYTICS_KEY, 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ domain: 'example.com', path: req.originalUrl, ip: req.ip, ua: req.get('user-agent'),
154
+ referrer: req.get('referer'), lang: req.get('accept-language'), purpose: req.get('sec-purpose') || req.get('purpose') }),
155
+ }).catch(() => {});
156
+ next();
157
+ });
158
+ ```
159
+
160
+ - **The visitor IP** is only accepted here, with a write key, never on the public `/api/collect`. It is hashed like a tracker hit (daily salt) and never stored. Behind a proxy or load balancer, send the real visitor IP (e.g. `req.ip` with Express `trust proxy` set right), not your proxy's. Without an IP the hit is refused (`400`), because all visitors would fall onto one hash.
161
+ - **Filtered on our side:** bots and monitors (same list as the tracker), requests without a user agent, prefetch / prerender (`purpose`), and `dnt: true` / `gpc: true` if you choose to forward those headers. A site-limited key can only report for its own sites.
162
+ - **Page caches and CDNs:** if a cache plugin or a CDN (e.g. Cloudflare) answers the request, your server code never runs and the visit is not reported. Report from somewhere every request passes, or exclude HTML from the cache.
163
+ - **What you give up compared with the script:** no time on page, no scroll depth, no click / outbound / download events, no country, and no `?tacilytics=off`. Server-side goals work: add `"name": "Purchase", "props": { "value": 49.9 }`.
164
+ - **Data protection:** the IP and user agent are personal data while they are hashed. That needs a legal basis (typically legitimate interest, GDPR Art. 6(1)(f)) and a sentence in your privacy policy. If someone else runs Tacilytics for you (for example Tacilytics Cloud), your server passes visitor IPs to them, so you need a data processing agreement (DPA). Server mode does not decide whether you need consent for the rest of your website. This is not legal advice.
165
+
118
166
  **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
167
 
120
168
  ## Mobile & desktop apps
@@ -225,6 +273,7 @@ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
225
273
  | `POST /api/v1/sites` | add a site `{ domain }` |
226
274
  | `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
227
275
  | `GET /api/v1/realtime?site=` | online count, 30-min pulse + recent events |
276
+ | `POST /api/v1/hit` | server mode: report a page view from your web server (write key), see [Server mode](#server-mode-no-script-at-all) |
228
277
 
229
278
  Add any of `country, source, page, browser, os, device, lang, medium, campaign, goal` to `/stats` to segment, e.g. `…/stats?site=example.com&period=30d&country=DE&medium=email`.
230
279
 
@@ -256,8 +305,9 @@ row per pageview. The dashboard aggregates it with plain SQL.
256
305
 
257
306
  **The cookieless trade-off, honestly:** because there’s no persistent identifier, Tacilytics
258
307
  counts unique visitors *per day* and can’t follow the same person across days or
259
- devices. You get the 95 % that matters (how many, which pages, from where) with 0 %
260
- cookie banner — the same approach as Plausible/Fathom.
308
+ devices. You get the 95 % that matters (how many, which pages, from where) without cookies,
309
+ the same approach as Plausible/Fathom. Whether you still need consent depends on your
310
+ jurisdiction and setup; strict mode (`data-strict`) reduces the device reads further.
261
311
 
262
312
  ## Light mode & German
263
313
 
@@ -273,9 +323,11 @@ Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`).
273
323
  | Role | Can |
274
324
  |---|---|
275
325
  | **Owner** | everything, including the team |
276
- | **Admin** | add/remove sites, share links, funnels |
326
+ | **Admin** | add/remove sites, share links, funnels — optionally limited to **specific sites** |
277
327
  | **Viewer** | read only — optionally limited to **specific sites** |
278
328
 
329
+ **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.
330
+
279
331
  Everyone can turn on, **optionally**:
280
332
 
281
333
  - **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
@@ -303,6 +355,7 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
303
355
  | `API_KEY` | — | Bearer key for the API / CLI |
304
356
  | `DB_PATH` | `./data/analytics.db` | SQLite path |
305
357
  | `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
358
+ | `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
359
  | `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
307
360
  | `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
308
361
  | `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 |
@@ -317,6 +370,8 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
317
370
  - **One process only.** The salt is drawn by the single Node process. Running several processes against the same `data/` would let each draw its own salt at midnight.
318
371
  - **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
319
372
  - **Respects DNT / Global Privacy Control** when you add `data-honor-dnt` to the tag.
373
+ - **Strict mode** (`data-strict`) skips the device reads (no localStorage, time zone, language or window size). See [Strict mode](#strict-mode).
374
+ - **Server mode** (`POST /api/v1/hit`) runs no script on the visitor's device at all; your server reports the page view. See [Server mode](#server-mode-no-script-at-all).
320
375
  - Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
321
376
  - Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
322
377
  - Passwords hashed with scrypt; session tokens, invite links and recovery codes are stored only as SHA-256. Sessions live in the database (survive restarts, expire after 30 days).
@@ -329,7 +384,9 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
329
384
 
330
385
  | | Tacilytics | Google Analytics |
331
386
  |---|---|---|
332
- | Cookies / consent banner | ❌ none | ✅ required |
387
+ | Cookies | ❌ none | ✅ yes |
388
+ | Reads from the device | a few (time zone, language, window size, one localStorage flag); none in strict mode | ✅ a lot |
389
+ | Works without any script on the visitor's device | ✅ server mode | ❌ |
333
390
  | Stores IPs / personal data | ❌ never | ✅ yes |
334
391
  | Self-hosted, you own the data | ✅ | ❌ |
335
392
  | Weight | ~2 KB | ~45 KB+ |
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.4.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); }
@@ -4,6 +4,11 @@
4
4
 
5
5
  Optional data-* flags on the <script> tag:
6
6
  data-honor-dnt respect Do-Not-Track / Global Privacy Control (skip all tracking)
7
+ data-strict strict mode: no localStorage, no timezone, no language, no window/screen size,
8
+ no DNT lookup in JS (only document.visibilityState and Date.now() differences for
9
+ time on page, never sent). Language comes from the Accept-Language header,
10
+ DNT/GPC from the request headers (with data-honor-dnt). Costs: no country, no scroll
11
+ depth, ?tacilytics=off does not work (exclude own visits another way).
7
12
  data-track-outbound auto-record clicks on links to other domains as events
8
13
  data-track-downloads auto-record file downloads as "File Download" events (optional value: "pdf,zip,…")
9
14
  data-track-404 record a "404" event when the page is an error page
@@ -20,15 +25,19 @@
20
25
  var domain = s && s.getAttribute('data-domain');
21
26
  if (!s || !domain) return;
22
27
 
28
+ var strict = s.hasAttribute('data-strict');
29
+ var honorDnt = s.hasAttribute('data-honor-dnt');
30
+
23
31
  // Privacy signals (opt-in): if the visitor asked not to be tracked, honour it and do nothing.
24
- if (s.hasAttribute('data-honor-dnt')) {
32
+ // In strict mode the server checks the DNT / Sec-GPC request headers instead (nothing is read here).
33
+ if (honorDnt && !strict) {
25
34
  var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
26
35
  if (navigator.globalPrivacyControl === true || dnt === '1' || dnt === 'yes') return;
27
36
  }
28
37
 
29
38
  // "Don't count this browser" (your own visits): open the site once with ?tacilytics=off
30
39
  // (?tacilytics=on counts it again). Remembered per browser in localStorage, nothing is sent.
31
- try {
40
+ if (!strict) try {
32
41
  var optParam = new URLSearchParams(location.search).get('tacilytics');
33
42
  if (optParam === 'off') { localStorage.setItem('tacilytics_ignore', '1'); console.info('Tacilytics: this browser is no longer counted on ' + location.hostname + ' (undo: ?tacilytics=on)'); }
34
43
  else if (optParam === 'on') { localStorage.removeItem('tacilytics_ignore'); console.info('Tacilytics: this browser is counted again on ' + location.hostname); }
@@ -37,11 +46,17 @@
37
46
  return;
38
47
  }
39
48
  } catch (e) {} // storage blocked → just count normally
49
+ else if (/[?&]tacilytics=(off|on)(&|$)/.test(location.search)) {
50
+ // the query string is part of the page address, not the device
51
+ try { console.info('Tacilytics: strict mode, ?tacilytics=off/on is not supported (it needs localStorage)'); } catch (e) {}
52
+ }
40
53
 
41
54
  var endpoint = new URL(s.src).origin + '/api/collect';
42
- var tz = '';
43
- try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
44
- var lang = (navigator.language || '').slice(0, 12);
55
+ var tz = '', lang = '';
56
+ if (!strict) {
57
+ try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
58
+ lang = (navigator.language || '').slice(0, 12);
59
+ }
45
60
  var qs = new URLSearchParams(location.search);
46
61
  var first = true;
47
62
 
@@ -68,6 +83,7 @@
68
83
  um: qs.get('utm_medium') || '',
69
84
  uc: qs.get('utm_campaign') || '',
70
85
  };
86
+ if (strict) { payload.st = 1; if (honorDnt) payload.hd = 1; }
71
87
  if (name) { payload.n = name; if (props && typeof props === 'object') payload.pr = props; }
72
88
  else first = false;
73
89
  post(payload);
@@ -77,6 +93,7 @@
77
93
  var acc = 0, since = (document.visibilityState === 'visible') ? Date.now() : 0, engPath = location.pathname;
78
94
  var maxScroll = 0;
79
95
  function measureScroll() {
96
+ if (strict) return; // window and screen size are device information
80
97
  var de = document.documentElement, b = document.body || de;
81
98
  var h = Math.max(de.scrollHeight, b.scrollHeight), vh = window.innerHeight || de.clientHeight;
82
99
  var y = window.pageYOffset || de.scrollTop || 0;
@@ -91,7 +108,11 @@
91
108
  function flushEngagement() {
92
109
  var secs = engaged();
93
110
  measureScroll();
94
- if (secs >= 1 || maxScroll > 0) post({ d: domain, p: engPath, e: 'engagement', dur: secs, sc: maxScroll });
111
+ if (secs >= 1 || maxScroll > 0) {
112
+ var eb = { d: domain, p: engPath, e: 'engagement', dur: secs };
113
+ if (strict) { eb.st = 1; if (honorDnt) eb.hd = 1; } else eb.sc = maxScroll;
114
+ post(eb);
115
+ }
95
116
  }
96
117
  function resetEngagement() { acc = 0; since = Date.now(); engPath = location.pathname; maxScroll = 0; }
97
118
  document.addEventListener('visibilitychange', function () {
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');
@@ -237,6 +239,11 @@ function parseUA(ua) {
237
239
 
238
240
  // Rough timezone → ISO country (privacy-preserving alternative to IP geo).
239
241
  const TZ_COUNTRY = require('./tz-country.js');
242
+ // first language of an Accept-Language header ("de-DE,de;q=0.9,en;q=0.8" → "de-DE"), or null
243
+ function acceptLang(h) {
244
+ const m = String(h || '').split(',')[0].split(';')[0].trim().match(/^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})?$/);
245
+ return m ? m[0].slice(0, 12) : null;
246
+ }
240
247
  function countryFromTz(tz) { return (tz && TZ_COUNTRY[tz]) || null; }
241
248
 
242
249
  const { createDaySalt, dayStr } = require('./day-salt.js');
@@ -317,7 +324,7 @@ function collect(req, res) {
317
324
  const domain = norm(b.d || b.domain);
318
325
  if (!domain) return res.status(204).end();
319
326
  const site = getSiteByDomain.get(domain);
320
- if (!site) return res.status(202).end(); // unknown site → silently ignore
327
+ if (!site || site.paused) return res.status(202).end(); // unknown or paused site → silently ignore
321
328
  const platform = appPlatform(b);
322
329
  // apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
323
330
  // A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
@@ -328,6 +335,10 @@ function collect(req, res) {
328
335
  ua = { browser: 'App', os: APP_PLATFORMS[platform], device: dv === 'tablet' ? 'Tablet' : dv === 'desktop' ? 'Desktop' : (platform === 'android' || platform === 'ios') ? 'Mobile' : 'Desktop' };
329
336
  }
330
337
  if (!ua) return res.status(204).end(); // bot
338
+ // strict mode (data-strict): the tracker read nothing on the device. Privacy signals and language come
339
+ // from headers the browser sends with every request anyway.
340
+ const strict = String(b.st || '') === '1';
341
+ if (strict && String(b.hd || '') === '1' && (req.headers['dnt'] === '1' || req.headers['sec-gpc'] === '1')) return res.status(204).end();
331
342
 
332
343
  const ts = Date.now();
333
344
  const day = dayStr(ts);
@@ -350,7 +361,7 @@ function collect(req, res) {
350
361
  // engagement beacon: attach a visit duration (+ max scroll depth) to the visitor's latest pageview (no new row).
351
362
  if (b.e === 'engagement') {
352
363
  const dur = Math.max(0, Math.min(7200, parseInt(b.dur, 10) || 0)); // cap at 2h
353
- const sc = b.sc == null || b.sc === '' ? null : Math.max(0, Math.min(100, parseInt(b.sc, 10) || 0));
364
+ const sc = strict || b.sc == null || b.sc === '' ? null : Math.max(0, Math.min(100, parseInt(b.sc, 10) || 0));
354
365
  if (dur > 0 || sc != null) updDur.run({ site_id: site.id, vh, path: pth, dur, scroll: sc, since: ts - 6 * 3600000 });
355
366
  return res.status(204).end();
356
367
  }
@@ -365,8 +376,8 @@ function collect(req, res) {
365
376
  }
366
377
  const utmMedium = String(b.um || b.utm_medium || '').slice(0, 60).trim() || null;
367
378
  const utmCampaign = String(b.uc || b.utm_campaign || '').slice(0, 80).trim() || null;
368
- const tz = String(b.tz || '').slice(0, 60) || null;
369
- const lang = String(b.l || b.lang || '').slice(0, 12).split(',')[0] || null;
379
+ const tz = strict ? null : (String(b.tz || '').slice(0, 60) || null);
380
+ const lang = strict ? acceptLang(req.headers['accept-language']) : (String(b.l || b.lang || '').slice(0, 12).split(',')[0] || null);
370
381
  const name = String(b.n || b.name || '').slice(0, 80).trim() || null; // custom event / goal
371
382
  const props = name ? sanitizeProps(b.pr) : null; // properties only attach to named events
372
383
  let country = countryFromTz(tz);
@@ -386,7 +397,7 @@ app.get('/api/collect', collect);
386
397
 
387
398
  // ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
388
399
  const { setupAuth } = require('./auth');
389
- const { auth, requireRole, siteAllowed, audit } = setupAuth({
400
+ const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAuth({
390
401
  app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
391
402
  allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
392
403
  });
@@ -411,6 +422,9 @@ function newToken() { return crypto.randomBytes(24).toString('hex'); }
411
422
  // ── sites management ─────────────────────────────────────────────────────────
412
423
  const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
413
424
  const sitesCreateHandler = (req, res) => {
425
+ // a site-limited account (a hosting customer) never creates sites itself: the operator does, because sites are what
426
+ // is billed and paused. Otherwise a paused customer could add a fresh, unpaused site and move the snippet (Kiro, 2.3.0).
427
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
414
428
  const rawDomain = norm(req.body && req.body.domain);
415
429
  let domain = rawDomain;
416
430
  let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
@@ -423,7 +437,17 @@ const sitesCreateHandler = (req, res) => {
423
437
  const rawTz = req.body && req.body.timezone;
424
438
  const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
425
439
  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);
440
+ const existing = getSiteByDomain.get(domain);
441
+ if (existing) {
442
+ // an account limited to some sites must never get a foreign site back (or added to its list) by naming its domain
443
+ if (!siteAllowed(req, existing.id)) return res.status(409).json({ error: 'This domain is already registered' });
444
+ return res.json(existing);
445
+ }
446
+ const info = insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
447
+ // defensive: if INSERT OR IGNORE did nothing (e.g. a second process sharing the DB won the domain), lastInsertRowid is
448
+ // stale and must never be granted. Not reachable over HTTP in one process (the handler is synchronous), so no test.
449
+ if (!info.changes) return res.status(409).json({ error: 'This domain is already registered' });
450
+ grantSite(req, Number(info.lastInsertRowid));
427
451
  audit(res, 'site.add', domain);
428
452
  res.json(getSiteByDomain.get(domain));
429
453
  };
@@ -435,15 +459,27 @@ app.patch('/api/sites/:id', adminOnly, (req, res) => {
435
459
  if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
436
460
  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
437
461
  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);
462
+ const b = req.body || {};
463
+ if (b.paused !== undefined) {
464
+ // pausing is the operator's switch (hosting: trial or subscription ended), never the customer's
465
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
466
+ const paused = b.paused ? 1 : 0;
467
+ db.prepare('UPDATE sites SET paused = ? WHERE id = ?').run(paused, id);
468
+ if (paused !== site.paused) audit(res, paused ? 'site.pause' : 'site.resume', site.domain);
469
+ }
470
+ if (b.timezone !== undefined || b.paused === undefined) {
471
+ const timezone = TZ.validTz(String(b.timezone || ''));
472
+ if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
473
+ db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
474
+ if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
475
+ }
442
476
  res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
443
477
  });
444
478
  app.delete('/api/sites/:id', adminOnly, (req, res) => {
445
479
  const id = Number(req.params.id);
446
480
  if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
481
+ // hard delete frees the domain and resets usage: only the operator (unlimited admin/owner) may do it (Tyto/Kiro, 2.3.0)
482
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
447
483
  const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
448
484
  audit(res, 'site.remove', gone ? gone.domain : '#' + id);
449
485
  db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
@@ -464,12 +500,78 @@ app.get('/api/v1', (_req, res) => res.json({
464
500
  'POST /api/v1/sites': 'add a site { domain }',
465
501
  'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
466
502
  'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
503
+ 'GET /api/v1/usage?month=YYYY-MM': 'page views per site in a calendar month (UTC), owner / server key only',
504
+ 'POST /api/v1/hit': 'server mode: report a page view from your web server { domain, path, ip, ua, referrer?, lang?, purpose?, name? } (write key)',
467
505
  },
468
506
  filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
469
507
  }));
470
508
  app.get('/api/v1/sites', auth, sitesListHandler);
509
+ // page views per site in one calendar month (UTC), for hosting volume checks. Operator only:
510
+ // owners or the server API key with access to all sites, never a site-limited account.
511
+ app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
512
+ if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
513
+ const m = String(req.query.month || new Date().toISOString().slice(0, 7));
514
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(m)) return res.status(400).json({ error: 'month must be YYYY-MM' });
515
+ 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);
516
+ 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));
517
+ });
471
518
  app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
472
519
 
520
+ // ── server mode (2.4): the website's own server reports a page view, no script on the visitor's device ──
521
+ // The site's backend forwards what the visitor's request carried anyway: path, Referer, Accept-Language,
522
+ // User-Agent and the visitor IP. The IP is only taken from an authenticated write key (never from the
523
+ // public /api/collect), otherwise anyone could feed arbitrary IPs into the visitor hash. It is hashed
524
+ // exactly like a tracker hit and never stored.
525
+ const net = require('net');
526
+ const PREFETCH_RE = /prefetch|prerender/i;
527
+ // write access needed (admin role or a non-read-only key); a read-only key is a viewer and gets 403
528
+ app.post('/api/v1/hit', adminOnly, (req, res) => {
529
+ if (rateLimited('h:' + clientIp(req), 6000, 60000)) return res.status(429).json({ error: 'Too many hits' });
530
+ const b = req.body || {};
531
+ const domain = norm(b.domain || b.d);
532
+ const site = domain && getSiteByDomain.get(domain);
533
+ if (!site || !siteAllowed(req, site.id)) return res.status(404).json({ error: 'Unknown site' });
534
+ if (site.paused) return res.status(202).end();
535
+ const ip = String(b.ip || '').trim();
536
+ // without the visitor IP every visitor would fall onto one hash: refuse instead of counting wrong
537
+ if (!net.isIP(ip)) return res.status(400).json({ error: 'ip (the visitor IP) is required for server mode' });
538
+ // prefetch / prerender requests are not visits (Sec-Purpose / Purpose header of the visitor request)
539
+ if (PREFETCH_RE.test(String(b.purpose || ''))) return res.status(204).end();
540
+ const rawUa = String(b.ua || '').slice(0, 512);
541
+ const ua = parseUA(rawUa);
542
+ if (!ua) return res.status(204).end(); // bot, crawler, monitor or no user agent
543
+ if (b.dnt === true || b.dnt === '1' || b.gpc === true || b.gpc === '1') return res.status(204).end(); // forwarded DNT / GPC (opt-in on the sending side)
544
+
545
+ const ts = Date.now();
546
+ const day = dayStr(ts);
547
+ let url;
548
+ try { url = new URL(String(b.path || b.p || '/').slice(0, 1024), 'http://x'); } catch { return res.status(400).json({ error: 'invalid path' }); }
549
+ let pth = url.pathname.slice(0, 512);
550
+ try { pth = decodeURI(pth); } catch {}
551
+ if (pth.length > 1) pth = pth.replace(/\/+$/, '') || '/';
552
+ const vh = visitorHash(site.id, ip, rawUa, day);
553
+ const intern = INTERNAL_PATHS[domain] || [];
554
+ if (intern.some((pre) => pth.startsWith(pre))) { insInternal.run(site.id, vh, day); return res.status(204).end(); }
555
+ if (isInternal.get(site.id, vh, day)) return res.status(204).end();
556
+
557
+ const referrer = String(b.referrer || b.r || '').slice(0, 512);
558
+ const q = url.searchParams;
559
+ let source = 'Direct';
560
+ const utm = String(q.get('utm_source') || q.get('ref') || '').slice(0, 60).trim();
561
+ if (utm) source = utm;
562
+ else if (referrer) { try { const h = norm(new URL(referrer).hostname); if (h && h !== domain) source = h; } catch {} }
563
+ const name = String(b.name || b.n || '').slice(0, 80).trim() || null; // optional server-side goal (e.g. Purchase)
564
+ insEvent.run({
565
+ site_id: site.id, ts, day, path: pth, source, referrer: referrer || null,
566
+ country: null, tz: null, browser: ua.browser, os: ua.os, device: ua.device,
567
+ lang: acceptLang(b.lang), name,
568
+ utm_medium: String(q.get('utm_medium') || '').slice(0, 60).trim() || null,
569
+ utm_campaign: String(q.get('utm_campaign') || '').slice(0, 80).trim() || null,
570
+ dur: null, props: name ? sanitizeProps(b.props) : null, vh, platform: null, app_version: null,
571
+ });
572
+ res.status(204).end();
573
+ });
574
+
473
575
  // ── stats ────────────────────────────────────────────────────────────────────
474
576
  const TZ = require('./tz');
475
577
  const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
@@ -983,6 +1085,16 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
983
1085
  });
984
1086
 
985
1087
  // ── static dashboard ─────────────────────────────────────────────────────────
1088
+ // Hosting (e.g. Tacilytics Cloud): DASHBOARD_EXTRA_SCRIPT="/cloud/ui.js" adds one same-origin script to the
1089
+ // dashboard page (trial/billing banner). Only a plain local path is accepted, never a URL to another host.
1090
+ 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 : '';
1091
+ const INDEX_HTML = path.join(__dirname, 'public', 'index.html');
1092
+ function sendIndex(_req, res) {
1093
+ if (!EXTRA_SCRIPT) return res.sendFile(INDEX_HTML);
1094
+ const html = fs.readFileSync(INDEX_HTML, 'utf8').replace('</body>', `<script src="${EXTRA_SCRIPT}" defer></script>\n</body>`);
1095
+ res.type('html').set('Cache-Control', 'no-cache').send(html);
1096
+ }
1097
+ app.get(['/', '/index.html'], sendIndex);
986
1098
  app.use(express.static(path.join(__dirname, 'public')));
987
1099
  // ── visitor journeys: session-based path transitions for a sankey flow ───────────
988
1100
  // A "session" = one visitor's pageviews with < 30 min between them.
@@ -1093,7 +1205,7 @@ app.get('/api/alerts', auth, (req, res) => {
1093
1205
  const site = getSiteByDomain.get(norm(req.query.site));
1094
1206
  if (!site) return res.status(404).json({ error: 'Unknown site' });
1095
1207
  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)) });
1208
+ 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
1209
  });
1098
1210
  app.post('/api/alerts', adminOnly, async (req, res) => {
1099
1211
  const b = req.body || {};
@@ -1118,7 +1230,10 @@ app.post('/api/alerts', adminOnly, async (req, res) => {
1118
1230
  if (prob) return res.status(400).json({ error: prob });
1119
1231
  if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
1120
1232
  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)' });
1233
+ } else if (!DIGEST_WEBHOOK_URL || isLimited(req)) {
1234
+ // the operator's webhook is for the operator: an account limited to some sites must bring its own
1235
+ return res.status(400).json({ error: 'Add a webhook URL' + (DIGEST_WEBHOOK_URL ? '' : ' (no default DIGEST_WEBHOOK_URL is set)') });
1236
+ }
1122
1237
  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
1238
  const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
1124
1239
  .run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
@@ -1173,7 +1288,7 @@ const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/
1173
1288
  const { createReport, REPORT_LANGS } = require('./report');
1174
1289
  const reporter = createReport(db);
1175
1290
  function buildReport(user, now) {
1176
- const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
1291
+ const sites = allSites.all().filter((st) => !st.paused && (!user.sites || JSON.parse(user.sites).includes(st.id)));
1177
1292
  return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
1178
1293
  }
1179
1294
  async function sendReport(user, now = Date.now()) {
@@ -1303,6 +1418,7 @@ const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
1303
1418
  async function alertTick(now = Date.now()) {
1304
1419
  const fired = [];
1305
1420
  for (const site of allSites.all()) {
1421
+ if (site.paused) continue; // a paused site collects nothing: a 'drop' alert would only announce the pause (Tyto, 2.3.0)
1306
1422
  const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
1307
1423
  const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
1308
1424
  let verdict = null;
@@ -1353,6 +1469,6 @@ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
1353
1469
  // unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
1354
1470
  // 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
1471
  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')));
1472
+ app.get('*', sendIndex);
1357
1473
 
1358
1474
  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` : ''}`));