@harshankur/viewcounter 3.0.1 → 3.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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  [![Documentation](https://img.shields.io/badge/docs-viewcounter.harshankur.com-blueviolet)](https://viewcounter.harshankur.com)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
- [![CI](https://github.com/harshankur/viewcounter/actions/workflows/ci.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/ci.yml)
6
- [![Tests](https://img.shields.io/badge/tests-343%20passing-success)](TEST_REPORT.md)
5
+ [![Test Suite](https://github.com/harshankur/viewcounter/actions/workflows/test.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
6
+ [![Tests](https://img.shields.io/badge/tests-1098%20passing-success)](TEST_REPORT.md)
7
7
  [![npm](https://img.shields.io/npm/v/@harshankur/viewcounter?logo=npm)](https://www.npmjs.com/package/@harshankur/viewcounter)
8
8
  [![provenance](https://img.shields.io/badge/provenance-signed-brightgreen?logo=github)](https://www.npmjs.com/package/@harshankur/viewcounter#provenance)
9
9
 
@@ -14,9 +14,10 @@ Visit our [Interactive Documentation](https://viewcounter.harshankur.com) for de
14
14
 
15
15
  ## 🛡️ GDPR Compliant & Privacy-First
16
16
  **100% GDPR Compliant By Design.** This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
17
- - **Zero Cookies**: No cookies, no local storage, and no consent banners required.
17
+ - **Nothing on the visitor's device**: no cookie, no localStorage, no sessionStorage, and no identifier sent with a view, so the tracker needs no consent banner under the ePrivacy rules on device storage. (The optional admin UI signs its operator in with a session cookie; tracking never sets one.)
18
18
  - **Data Sovereignty**: You own your data. Analytics never leave your private infrastructure.
19
- - **Minimal Collection**: Tracks only what is necessary (Country, Browser, OS, Page Path).
19
+ - **Minimal Collection**: records what analytics needs, each in a form that does not identify a person. [What Gets Tracked?](#what-gets-tracked) lists every field, where it comes from, and how it is stored; the raw IP address, the user agent, and the query string are never stored.
20
+ - **Bots left out**: crawlers, link previewers, and automated browsers are recognised and never stored, only counted per minute by name.
20
21
 
21
22
  ### 🔄 Data Privacy Lifecycle
22
23
  ```mermaid
@@ -48,19 +49,24 @@ We believe in total transparency regarding your visitors' data:
48
49
  - 🗄️ **Flexible Database**: Connect to existing DB or auto-create schema
49
50
  - 🛠️ **Easy Setup**: Interactive CLI wizard with config detection
50
51
  - 🏥 **Production-Ready**: Health checks, graceful shutdown, structured logging
52
+ - 🧑‍💼 **Admin UI**: Browse, search, edit, annotate, and soft-delete recorded views, with batch actions, a trash, and audit logs ([details](#admin-ui))
51
53
 
52
54
  ### Advanced Tracking
53
- - 📍 **Page Tracking**: Track specific pages/paths, not just app-level
54
- - 🔗 **Referrer Analysis**: Automatic source categorization (search, social, email, campaign, referral, direct)
55
- - 🖥️ **User Agent Parsing**: Browser, OS, and device type detection
56
- - 👤 **Session Tracking**: Group views by user session
57
- - 🎯 **Custom Events**: Track button clicks, form submissions, etc.
58
- - 📊 **Time-Based Analytics**: Hourly, daily, and weekly trends
55
+ - 🧩 **Tracker script**: one `<script>` tag, served by your ViewCounter server ([details](#client-side-integration)): page views, including page changes in single-page apps; how long each page was visible and how far it was scrolled; clicks on links to other sites and on downloads; campaign tags. It stores nothing on the device.
56
+ - 📍 **Pages and sites**: the page path, its title, and which of your sites (hostname) it was on
57
+ - 🔗 **Referrer Analysis**: automatic source categorization (search, social, email, campaign, referral, internal, direct)
58
+ - 🏷️ **Campaigns**: the five `utm_*` tags of the landing URL, and nothing else from it
59
+ - 🌍 **Location**: country built in; region and city with an optional [city database](#location-data)
60
+ - 🗣️ **Language**: the visitor's preferred language (its primary subtag only, such as `de`)
61
+ - 🖥️ **User Agent Parsing**: browser, OS, and device type, with their versions
62
+ - ⏱️ **Engagement**: time on page and scroll depth, measured by the tracker script
63
+ - 🎯 **Custom Events**: track button clicks, form submissions, etc., with properties
64
+ - 📊 **Analysis**: visitors, visits, bounce rate, visit duration, entry and exit pages, page flow, a weekday-by-hour heatmap, and comparisons with the period before, in the [admin UI](#admin-ui)
59
65
 
60
66
  ## Quick Start
61
67
 
62
68
  **Requires Node 24 or newer** and a reachable MySQL 8 (or MariaDB 11) instance.
63
- Only the current Node LTS is supported — no matrix of older runtimes to
69
+ Only the current Node LTS is supported, with no matrix of older runtimes to
64
70
  maintain.
65
71
 
66
72
  ### 1. Install Dependencies
@@ -100,6 +106,16 @@ npm start
100
106
  }
101
107
  ```
102
108
 
109
+ On every start, in either mode, the server brings each app table up to the
110
+ current schema and creates the log tables it needs, so the user needs `CREATE`,
111
+ `ALTER`, and `INDEX` on the database as well as `SELECT`, `INSERT`, `UPDATE`,
112
+ and `DELETE`. A user limited to reads and writes, which was enough before 3.1,
113
+ fails startup with `MIGRATION_FAILED`. Grant these before upgrading:
114
+
115
+ ```sql
116
+ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON viewcounterdb.* TO 'vcuser'@'%';
117
+ ```
118
+
103
119
  **Create Mode**: Auto-create database and tables
104
120
  ```json
105
121
  // dbInfo.json
@@ -124,7 +140,7 @@ npm start
124
140
  ### Environment Variables
125
141
  See [`.env.example`](.env.example) for the full surface with prose on each one.
126
142
 
127
- **Required in production** — the server refuses to start without these rather
143
+ **Required in production**: the server refuses to start without these rather
128
144
  than running on a guessable default:
129
145
  - `DB_USER` / `DB_PASSWORD`: refuses to boot while still `root` with an empty password
130
146
  - `DB_NAME`: database to write into
@@ -133,11 +149,204 @@ than running on a guessable default:
133
149
 
134
150
  **Recommended**:
135
151
  - `READ_API_KEYS`: comma-separated keys for the analytics read endpoints. Unset means the read API is disabled.
136
- - `TRUST_PROXY`: hop count or CIDR list. **Never set this to `true`** — trusting every hop lets any caller forge their own IP via `X-Forwarded-For`, which fakes geolocation, inflates unique-visitor counts, and bypasses rate limiting. `true` and `*` are downgraded to one hop with a warning. Your proxy must set `X-Forwarded-For`; `X-Real-IP` alone is not read.
152
+ - `TRUST_PROXY`: hop count or CIDR list. **Never set this to `true`**: trusting every hop lets any caller forge their own IP via `X-Forwarded-For`, which fakes geolocation, inflates unique-visitor counts, and bypasses rate limiting. `true` and `*` are downgraded to one hop with a warning. Your proxy must set `X-Forwarded-For`; `X-Real-IP` alone is not read.
137
153
  - `VISITOR_SECRET_PATH` / `VISITOR_SECRET`: where the visitor-hash secret lives, or the value itself.
154
+ - `ADMIN_PASSWORD`: turns on the [admin UI](#admin-ui) at `/admin`. At least 16 characters. Unset means the admin UI does not exist.
138
155
 
139
156
  **Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
140
- `RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`.
157
+ `RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
158
+ `TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS`, `ADMIN_SESSION_IDLE_TIMEOUT`,
159
+ `ADMIN_SESSION_MAX_AGE`, `GEOIP_CITY_DB`.
160
+
161
+ ### Time zones
162
+
163
+ Views are timestamped by the database (`NOW()`), and the admin analysis groups
164
+ them into UTC days and hours whatever the database's time zone. Run ViewCounter
165
+ and the database in the same time zone, so times read back unchanged; in
166
+ containers both default to UTC.
167
+
168
+ ### Location data
169
+
170
+ **Country** comes from the GeoLite2 country database bundled in the
171
+ `geoip-country` package: nothing to configure. MaxMind updates it monthly and
172
+ the package follows, so update the package (or rebuild your image) now and
173
+ then. GeoLite2's licence asks for credit, which the admin UI shows: *This
174
+ product includes GeoLite2 data created by MaxMind, available from
175
+ https://www.maxmind.com.*
176
+
177
+ **Region and city** need a city database. Set `GEOIP_CITY_DB` to a
178
+ MaxMind-format `.mmdb` file:
179
+
180
+ - [DB-IP IP to City Lite](https://db-ip.com/db/download/ip-to-city-lite),
181
+ free under CC BY 4.0 and updated monthly; the admin UI credits it as
182
+ *IP geolocation by DB-IP*, as its licence asks;
183
+ - or MaxMind GeoLite2 City, which needs a free MaxMind account.
184
+
185
+ The file is read again whenever it changes, so a monthly job that downloads a
186
+ new one over it needs no restart. Without one, region and city stay empty and
187
+ everything else works. A configured path that cannot be opened stops the
188
+ server from starting, rather than running silently without cities. The IP is
189
+ looked up in memory only, like the country, and is never stored.
190
+
191
+ ## Admin UI
192
+
193
+ A web interface for the data ViewCounter has recorded, served by the same
194
+ server at `/admin`. It is built into the package: set a password and it is
195
+ there, with no separate deployment and no build step.
196
+
197
+ ```bash
198
+ # .env (or the environment of your container)
199
+ ADMIN_PASSWORD=<at least 16 characters, e.g. from: openssl rand -base64 24>
200
+ TRASH_RETENTION_DAYS=30 # optional; 0 keeps trash until emptied by hand
201
+ VIEW_LOG_RETENTION_DAYS=90 # optional; 0 keeps the tracking log forever
202
+ ADMIN_SESSION_IDLE_TIMEOUT=7d # optional; a session ends after this long unused
203
+ ADMIN_SESSION_MAX_AGE=30d # optional; and this long after signing in
204
+ ```
205
+
206
+ Then open `https://<your-server>/admin/` and sign in.
207
+
208
+ To look around without a database, `npm run admin:demo` serves the admin UI
209
+ at http://localhost:4173/admin/ over several thousand fake views (password
210
+ `playwright-admin-password`). Nothing in it is real traffic.
211
+
212
+ ### What you can do
213
+
214
+ The admin has five sections. Every one but the two logs shows one app, or
215
+ every app together under **All apps**; the choice follows you between them.
216
+
217
+ - **Overview** (where it opens) is the analysis, for a period (24 hours, 7,
218
+ 30, or 90 days, a year, or all time) and an event type:
219
+ - nine headline numbers (visitors, visits, page views, views and events,
220
+ bounce rate, visit duration, pages per visit, time on page, scroll depth),
221
+ each against the period before, with a sparkline; choose one to chart it
222
+ over time, or read every number per period as a table;
223
+ - **Right now**: visitors in the last few minutes, views per minute over
224
+ the last half hour, and the pages open, refreshed while you look;
225
+ - where visits come from (channels, referrers, referring pages, and every
226
+ campaign tag), pages (top, entry with bounce rate, exit, titles, sites),
227
+ locations (a world map, countries, regions, cities, languages), devices,
228
+ browsers and systems with their versions, custom events and their
229
+ properties, time-on-page and scroll-depth distributions, page flow (which
230
+ page led to which), and a weekday-by-hour heatmap in your time zone;
231
+ - click any row to narrow everything to it (a chip above takes it off
232
+ again), and **Show these views** to open exactly those rows in Views.
233
+ "How these numbers are counted", at the bottom, defines each number.
234
+ - **Views** is the data itself: every view and event recorded, one row each,
235
+ the rows every Overview number is computed from. Filter by period, event
236
+ type, and whether an admin changed a row; search by page, title, site,
237
+ source, campaign, note, event, or view ID; sort by any column. **Columns**
238
+ chooses which columns the table has, from everything a view stores, and their
239
+ order; drag a column's edge to resize it (arrow keys work too). The table
240
+ shows as many of your columns as fit its width, in your order, and keeps the
241
+ rest of each row one tap away under it, so a wide screen shows more and
242
+ nothing scrolls sideways. On a phone each view is a card of your first few
243
+ columns. The two logs choose their columns the same way, and the choices are
244
+ remembered in your browser. From here you can:
245
+ - **select several views**, across pages and apps, and act on all at once;
246
+ - **edit content fields**: page path, page title, referrer (the source is
247
+ recalculated from it), device size, event type, and event data. What was
248
+ *observed* about the visitor (time, masked IP, location, language,
249
+ browser, OS, device type, engagement) is never editable, so an edit can
250
+ correct what was viewed but never fabricate who viewed it or when;
251
+ - **add a note** to any view, as a private annotation;
252
+ - **see every stored field** of a view, grouped, in its details;
253
+ - **move views to the trash**, where they stop counting in every statistic
254
+ at once.
255
+ - **Trash** holds what was moved there, to **restore** or **erase
256
+ permanently**.
257
+ - **Tracking log** lists every tracking request that reached the server and
258
+ what became of it: recorded, a repeat visit, a bot, or refused (and why:
259
+ an unregistered site, an unknown app, a malformed request, a rate limit).
260
+ Views holds only what was recorded; this log also shows what never was, so
261
+ it is where to check that a site is sending views, or find out why some are
262
+ not counted. It sums up the last day, filters by app, request type, and
263
+ outcome, can refresh itself, and opens any entry's view in Views. It is not
264
+ a statistic, and editing or deleting a view never changes it.
265
+ - **Admin log** lists every sign-in and every change made here.
266
+
267
+ The header links to this project's website and names the running version; the
268
+ footer links to the documentation, changelog, source, and package, carries the
269
+ copyright notice, and credits the location data.
270
+
271
+ ### How the data is kept
272
+
273
+ | Column | Meaning |
274
+ |---|---|
275
+ | `public_id` | Random UUID that identifies a view in the UI and API. The auto-increment row number never leaves the server. |
276
+ | `admin_modified_at` | Empty when the row is exactly as recorded; otherwise when an admin last changed its content. Notes do not set it. |
277
+ | `note` | The admin's annotation, if any. |
278
+ | `deleted_at` | Empty for live rows; set when the row went to the trash. |
279
+
280
+ These columns, the tracking columns in [What Gets Tracked?](#what-gets-tracked),
281
+ and the `_admin_log`, `_view_log`, `_tracking_rejections`, and
282
+ `_admin_sessions` tables are added automatically when the server starts, in both database modes, whether or not
283
+ the admin UI is enabled. The upgrade is additive: nothing is dropped, and
284
+ existing rows get their `public_id` on the first start, in batches that each
285
+ resume where the last stopped, so a large table is read once. The database user
286
+ therefore needs `CREATE`, `ALTER`, and `INDEX` as well as the usual privileges
287
+ (see [Database Modes](#database-modes)).
288
+
289
+ | Table | Holds |
290
+ |---|---|
291
+ | `_view_log` | The tracking log's accepted half: one row per view or event recorded, with its app, site, request type, view ID, and whether it was unique. |
292
+ | `_tracking_rejections` | The tracking log's other half: per minute, how many requests were bots or refused, by app, request type, reason, site, and a short detail (a bot's name, the invalid field). Nothing about who sent them. |
293
+ | `_admin_log` | Every sign-in and every change made in the admin. |
294
+ | `_admin_sessions` | Signed-in admin sessions: a SHA-256 of the session token (never the token), when it was created and last used, and when the password was last entered. |
295
+
296
+ The tracking log grows with traffic, so entries older than
297
+ `VIEW_LOG_RETENTION_DAYS` (default 90) are removed hourly, in batches, and each
298
+ run that removes anything is recorded in the admin log. It holds no personal
299
+ data, so this only bounds its size; the views themselves are untouched. The
300
+ admin log is never pruned: it is the record of who changed or erased what, and
301
+ it grows only with admin activity.
302
+
303
+ ### Deleting, and GDPR
304
+
305
+ Deleting is always a soft delete first. Views in the trash are erased for good
306
+ after `TRASH_RETENTION_DAYS` (default 30), or straight away with **Erase
307
+ permanently**, which exists so a data subject's erasure request (GDPR
308
+ Art. 17) can be honoured completely. Each erasure is recorded in the admin
309
+ log.
310
+
311
+ Neither log copies personal data, so erasing a row really erases it:
312
+
313
+ - the admin log records who acted (a session ID and a masked IP), what they did,
314
+ when, to which view IDs, and *which* fields changed, but never the values;
315
+ - the tracking log records that a view was accepted, when, for which app and
316
+ site, and through which endpoint, with no IP, visitor hash, or user agent;
317
+ refused requests and bots are only counted, per minute.
318
+
319
+ ### Security
320
+
321
+ - `ADMIN_PASSWORD` is its own credential tier. It is independent of
322
+ `READ_API_KEYS` and `ADMIN_API_KEYS`, so leaking one never unlocks another,
323
+ and the server warns if you reuse an API key as the password.
324
+ - Signing in issues an `HttpOnly`, `SameSite=Strict` session cookie scoped to
325
+ the admin path (`/admin`, or wherever an embedding app mounts it), marked `Secure` whenever the request arrived over HTTPS (through
326
+ `TRUST_PROXY` behind a proxy).
327
+ - Sessions are kept in the database as a hash of their token, so a restart
328
+ does not sign anyone out. One ends after 7 days without use or 30 days after
329
+ signing in (`ADMIN_SESSION_IDLE_TIMEOUT`, `ADMIN_SESSION_MAX_AGE`). Using the
330
+ UI keeps it alive, reading included. If it ends mid-use, a sign-in dialog
331
+ opens over the page and whatever you were doing carries on after it.
332
+ - Erasing permanently asks for the password again unless it was entered in the
333
+ last 15 minutes, whatever the session's age; a wrong one counts toward the
334
+ sign-in limit.
335
+ - Every change also needs a per-session CSRF token and a matching `Origin`.
336
+ - Wrong passwords are rate limited per IP (5 per 15 minutes) and recorded in
337
+ the admin log. Requests refused before the password is checked, such as a
338
+ foreign `Origin` or a malformed body, do not count toward the limit.
339
+ - Behind a TLS-terminating proxy, set `TRUST_PROXY` and have the proxy pass
340
+ `X-Forwarded-Proto` and the original `Host`. Otherwise the server believes it
341
+ is serving `http://`, the browser's `https://` `Origin` does not match, and
342
+ every sign-in is refused as cross-origin. The server logs
343
+ `ADMIN_ORIGIN_REJECTED` with both origins when that happens.
344
+ - The UI runs under a strict Content Security Policy (no inline script or
345
+ style, no third-party origins, not frameable) and renders everything as
346
+ text: page titles and referrers come from anonymous visitors and can never
347
+ execute.
348
+ - Serve it over HTTPS only. The server logs a warning when a sign-in arrives
349
+ over plain HTTP in production.
141
350
 
142
351
  ## API Endpoints
143
352
 
@@ -151,20 +360,51 @@ GET /registerView?appId=blog&deviceSize=medium
151
360
  # Enhanced with page tracking
152
361
  GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&title=My%20Post
153
362
 
154
- # With referrer and session
155
- GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&sessionId=abc123
363
+ # With referrer and campaign tags
364
+ GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&utm_source=newsletter&utm_campaign=launch
156
365
  ```
157
366
 
158
- **Automatic tracking:**
159
- - ✅ IP address and geolocation
160
- - ✅ Browser, OS, device type (from User-Agent)
161
- - ✅ Referrer domain and source type
367
+ `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and `utm_content` are
368
+ the only parts of a URL's query that are kept (100 characters each); a landing
369
+ that carries one counts as a `campaign`. `sessionId` is optional and yours to
370
+ define; the tracker script never sends one.
371
+
372
+ **Recorded by the server:**
373
+ - ✅ Country (and region and city with a [city database](#location-data)), from the IP, which is then masked
374
+ - ✅ Browser, OS, device type, and their versions (from the User-Agent, which is not kept)
375
+ - ✅ The site visited (the hostname of the request's `Origin`) and the visitor's language (the primary subtag of `Accept-Language`)
376
+ - ✅ Referrer domain and source type; a referrer on the same site is `internal`
162
377
  - ✅ Duplicate prevention (configurable window)
378
+ - ✅ Bots are answered but never stored
163
379
 
164
- **Response**:
380
+ **Response**:
165
381
  ```json
166
- {"message": "Success!", "duplicate": false}
382
+ {"message": "Success!", "duplicate": false, "recorded": true, "id": "0b8c3c1e-3b1c-4f2c-9d4e-1a2b3c4d5e6f"}
383
+ ```
384
+ `id` is the view's public ID, which `/engage` takes. A bot gets
385
+ `{"recorded": false}` and a `200`, so it has no reason to retry.
386
+
387
+ #### Report Engagement
388
+ ```bash
389
+ POST /engage
390
+ Content-Type: text/plain # or application/json
391
+
392
+ {"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
167
393
  ```
394
+ How long the page was visible (`ms`, up to 6 hours) and how much of it had been
395
+ on screen (`scroll`, 0 to 100). A later report can only raise either. A report
396
+ for a view that is unknown, trashed, or older than a day changes nothing and is
397
+ counted in the tracking log as refused. `text/plain` is accepted so
398
+ `navigator.sendBeacon` can deliver it as the page closes, without a CORS
399
+ preflight. Answers `204`.
400
+
401
+ #### The Tracker Script
402
+ ```bash
403
+ GET /tracker.js
404
+ ```
405
+ The script in [Client-Side Integration](#client-side-integration), served by
406
+ the server it reports to, so the two never drift apart. Other sites may load
407
+ it (`Cross-Origin-Resource-Policy: cross-origin`), and it is cached for an hour.
168
408
 
169
409
  #### Track Custom Event
170
410
  ```bash
@@ -175,17 +415,20 @@ Content-Type: application/json
175
415
  "appId": "blog",
176
416
  "eventType": "button_click",
177
417
  "eventData": {"button": "subscribe", "location": "header"},
178
- "sessionId": "abc123",
179
- "page": "/blog/my-post"
418
+ "page": "/blog/my-post",
419
+ "title": "My post"
180
420
  }
181
421
  ```
422
+ **Response**: `{"message": "Event tracked successfully", "recorded": true, "id": "<public ID>", "insertId": 42}`.
423
+ `insertId`, the internal row number, is deprecated and will be removed in 4.0;
424
+ use `id`. Custom events are never deduplicated.
182
425
 
183
426
  ### 📈 Analytics
184
427
 
185
428
  > **These endpoints require authentication.** They return your analytics data,
186
429
  > so every one of them expects a valid key in the `x-api-key` header. Configure
187
430
  > keys via `READ_API_KEYS` (comma-separated, minimum 32 characters each). With
188
- > none configured the read API returns `503` — it fails closed rather than
431
+ > none configured the read API returns `503`: it fails closed rather than
189
432
  > serving your data to anyone who asks.
190
433
  >
191
434
  > ```bash
@@ -366,79 +609,95 @@ Set `NODE_ENV=production` to hide error details in API responses.
366
609
 
367
610
  ## What Gets Tracked?
368
611
 
369
- For each view/event, the system automatically captures:
370
-
371
- | Field | Source | Description |
372
- |-------|--------|-------------|
373
- | **IP Address** | Request | Visitor IP |
374
- | **Country** | GeoIP lookup | 2-letter country code |
375
- | **Timestamp** | Server | When the event occurred |
376
- | **Device Size** | Query param | small, medium, large |
377
- | **Page Path** | Query param (optional) | e.g., `/blog/my-post` |
378
- | **Page Title** | Query param (optional) | e.g., "My Blog Post" |
379
- | **Referrer** | Header/query (optional) | Full referrer URL |
380
- | **Referrer Domain** | Parsed | e.g., `google.com` |
381
- | **Source Type** | Parsed | search, social, email, campaign, referral, direct |
382
- | **Browser** | User-Agent | e.g., Chrome, Safari, Firefox |
383
- | **Browser Version** | User-Agent | e.g., 120.0 |
384
- | **OS** | User-Agent | e.g., Windows, Mac OS, Linux |
385
- | **OS Version** | User-Agent | e.g., 10, 14.2 |
386
- | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console |
387
- | **Session ID** | Query param (optional) | Group events by session |
388
- | **Event Type** | Query param/body | pageview, click, submit, etc. |
389
- | **Event Data** | Body (optional) | Custom JSON data |
612
+ Every field of a view, where it comes from, and the form it is stored in.
613
+ Nothing here identifies a person: the one pseudonymous value, the visitor
614
+ hash, changes every `UNIQUE_VISITOR_WINDOW_HOURS` and is never shown or
615
+ returned by any API.
616
+
617
+ | Field | Comes from | Stored as | Why |
618
+ |-------|-----------|-----------|-----|
619
+ | **Timestamp** | Server | When the view was recorded | Everything over time |
620
+ | **Masked IP** | Request | IPv4 with the last octet zeroed, IPv6 with the interface identifier zeroed | Abuse investigation at network level, never a person |
621
+ | **Visitor hash** | IP and User-Agent, with a secret | HMAC-SHA-256, keyed with a server secret, rotating every window | Unique views, visitors, and visits; never returned |
622
+ | **Country** | IP, looked up in memory | Two-letter code | Where visitors are |
623
+ | **Region, City** | IP, with an optional [city database](#location-data) | Names, such as Bavaria and Munich | Where visitors are, more finely |
624
+ | **Language** | `Accept-Language` | Primary subtag only, such as `de` (never `de-CH`, never a list) | Which languages to write in |
625
+ | **Site** | `Origin` of the request | Hostname only, such as `blog.example.com` | Several sites or subdomains on one app |
626
+ | **Page Path** | `page` | Path only, such as `/blog/my-post` (the tracker never sends a query string) | Which pages are read |
627
+ | **Page Title** | `title` | Text, up to 200 characters | Readable page names |
628
+ | **Referrer** | `referrer` (the page's `document.referrer`) | Origin and path only: the query string and fragment are dropped (the tracker never sends them); absent or empty means direct | Where visits come from |
629
+ | **Referrer Domain, Source Type** | Derived from the referrer | Hostname; search, social, email, campaign, referral, internal, or direct | Grouping sources |
630
+ | **Campaign** | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | Up to 100 characters each; no other query key is ever kept | Which campaigns work |
631
+ | **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
632
+ | **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
633
+ | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
634
+ | **Time on page, Scroll depth** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen | Whether pages are read |
635
+ | **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
636
+ | **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
637
+
638
+ **Never stored**: the raw IP address, the User-Agent string, cookies or any
639
+ other identifier from the device, any query string or fragment (of the page or
640
+ of its referrer) apart from the page's campaign tags, and anything about bots
641
+ or refused requests beyond a per-minute count. Before 3.2, a referrer was stored
642
+ as sent, query string included; the changelog shows how to strip older rows.
643
+
644
+ **Visits** are read from the visitor hash the way privacy-first analytics does
645
+ it: a visitor's page views belong to one visit until they pause for 30
646
+ minutes. Because the hash rotates, the same person on two days is two
647
+ visitors, and nothing links them.
648
+
649
+ Your site's privacy notice should still say that you measure visits this way,
650
+ and why (legitimate interest in understanding how the site is used). What you
651
+ send in `eventData` and `sessionId` is yours to keep free of personal data.
390
652
 
391
653
  ## Understanding `UNIQUE_VISITOR_WINDOW_HOURS`
392
654
 
393
655
  This setting prevents counting the same visitor multiple times within a time window.
394
656
 
395
657
  **How it works:**
396
- - When a view is registered, the system checks if the same IP has visited within the last X hours
397
- - If yes: Returns `{duplicate: true}` (doesn't count again)
398
- - If no: Inserts new view
658
+ - When a view is registered, the system checks whether the same visitor hash
659
+ (the same IP and browser, within the current window) already viewed the app
660
+ - If yes: the view is stored as a repeat (`{duplicate: true}`), which counts as
661
+ a view but not as a unique view
662
+ - If no: it is stored as a unique view
663
+
664
+ The window is also how often the visitor hash rotates, so it bounds how long
665
+ the same person counts as one visitor.
399
666
 
400
667
  **Examples:**
401
- - `24` (default): Same IP counts as 1 view per day
402
- - `0`: Disable duplicate prevention (count every request)
403
- - `168`: Same IP counts as 1 view per week
668
+ - `24` (default): the same visitor counts once per day
669
+ - `0`: disable duplicate prevention (the hash still rotates hourly)
670
+ - `168`: the same visitor counts once per week
404
671
 
405
672
  **Note:** Only applies to `pageview` events, not custom events.
406
673
 
407
674
  ### 🛡️ Privacy Guardrails (Fail-Safe)
408
- To guarantee that raw IPs never leak into the database, we've implemented an automated **Privacy Guard** suite ([privacyFailSafe.test.js](file:///Users/harshankur/Desktop/codes/viewcounter/tests/privacyFailSafe.test.js)):
675
+ To guarantee that raw IPs never leak into the database, we've implemented an automated **Privacy Guard** suite ([privacyFailSafe.test.js](tests/privacyFailSafe.test.js)):
409
676
  - **Query Interception**: Every single SQL `INSERT` is intercepted during tests.
410
677
  - **Regex Scanning**: We scan all query parameters against raw IP patterns (IPv4 and IPv6).
411
678
  - **Hard Enforcement**: If the system ever attempts to save an unmasked IP, the test suite immediately fails, preventing accidental privacy regressions.
412
679
 
413
680
  This makes ViewCounter not just "Privacy-First" by design, but **Privacy-Guaranteed** by automation.
414
681
 
415
- - [x] Implement IP masking utility
416
- - [x] Implement transient hashing for uniqueness
417
- - [x] Update `DatabaseManager` to use hashes/masked IPs
418
- - [x] Update `db/schema.sql` (column renaming/clarification)
419
- - [x] Remove "IP Address" references from docs/README
420
- - [x] Update documentation with "How it works" privacy section
421
- - [x] Update and verify tests
422
-
423
682
  ## Security Features
424
683
 
425
684
  **Trust model.** The two write endpoints (`/registerView`, `/event`) are public
426
685
  because a browser on your site must be able to reach them. Everything that
427
686
  *reads* analytics is authenticated.
428
687
 
429
- - ✅ **Authenticated, scoped read API** — every analytics endpoint requires `x-api-key`, compared in constant time, and each key is authorized against the specific `appId` requested. Fails closed when unconfigured.
430
- - ✅ **Separate admin tier** — provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
431
- - ✅ **Per-tenant rate limits** — an `appId`-keyed budget alongside the per-IP limit.
432
- - ✅ **Keyed visitor hashing** — HMAC-SHA-256 with a persisted 32-byte server secret, rotating per window, so stored hashes are not reversible to an IP.
433
- - ✅ **SQL injection prevention** — every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
434
- - ✅ **Explicit CORS allowlist** — no wildcard, and writes can be bound to registered origins per app.
435
- - ✅ **Proxy-aware IP derivation** — client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
436
- - ✅ **Bounded input** — length caps matching every column width, integer ranges on `limit`/`days`/`offset`, a 16 kB body cap and a 4 kB `eventData` cap.
437
- - ✅ **Resource guards** — finite pool queue, per-statement timeout, rate limiting.
438
- - ✅ **No error leakage** — failures return a request id; the detail goes only to the server log.
688
+ - ✅ **Authenticated, scoped read API**: every analytics endpoint requires `x-api-key`, compared in constant time, and each key is authorized against the specific `appId` requested. Fails closed when unconfigured.
689
+ - ✅ **Separate admin tier**: provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
690
+ - ✅ **Per-tenant rate limits**: an `appId`-keyed budget alongside the per-IP limit.
691
+ - ✅ **Keyed visitor hashing**: HMAC-SHA-256 with a persisted 32-byte server secret, rotating per window, so stored hashes are not reversible to an IP.
692
+ - ✅ **SQL injection prevention**: every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
693
+ - ✅ **Explicit CORS allowlist**: no wildcard, and writes can be bound to registered origins per app.
694
+ - ✅ **Proxy-aware IP derivation**: client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
695
+ - ✅ **Bounded input**: length caps matching every column width, integer ranges on `limit`/`days`/`offset`, a 16 kB body cap and a 4 kB `eventData` cap.
696
+ - ✅ **Resource guards**: finite pool queue, per-statement timeout, rate limiting.
697
+ - ✅ **No error leakage**: failures return a request id; the detail goes only to the server log.
439
698
  - ✅ **Security headers** (Helmet.js) and `Cache-Control: no-store` on all analytics responses.
440
- - ✅ **Fail-fast config validation** — insecure defaults stop the boot rather than being silently accepted.
441
- - ✅ **Adversarial regression suite** — [`tests/security.test.js`](tests/security.test.js) covers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
699
+ - ✅ **Fail-fast config validation**: insecure defaults stop the boot rather than being silently accepted.
700
+ - ✅ **Adversarial regression suite**: [`tests/security.test.js`](tests/security.test.js) covers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
442
701
 
443
702
  Report a vulnerability through [private advisory reporting](https://github.com/harshankur/viewcounter/security/advisories/new), not a public issue. See [SECURITY.md](SECURITY.md).
444
703
 
@@ -462,6 +721,11 @@ X-Forwarded-For: <real visitor IP> # set by your code
462
721
  TRUST_PROXY=1 # otherwise the header is ignored
463
722
  ```
464
723
 
724
+ The same goes for the referrer: pass the visitor's own `Referer` (from the
725
+ request your server received) as the `referrer` query parameter. ViewCounter
726
+ never reads the `Referer` header on the request it receives, because from a
727
+ browser that header names the tracked page, not where the visitor came from.
728
+
465
729
  **A process with no visitor at all** (cron, CLI, worker, webhook) should use
466
730
  `POST /event`. Custom events are never deduplicated, so "unique visitors" is
467
731
  simply not a meaningful column for those rows.
@@ -489,7 +753,7 @@ router.afterEach(() => track()); // Vue / Nuxt
489
753
  useEffect(() => track(), [pathname]); // Next.js app router
490
754
  ```
491
755
 
492
- Run one instance for all your sites — one `appId` each, each with its own table
756
+ Run one instance for all your sites: one `appId` each, each with its own table
493
757
  and its own origin list.
494
758
 
495
759
  ## Multi-Tenancy
@@ -530,7 +794,7 @@ out-of-scope one, for the same reason.
530
794
  ### Provisioning a tenant at runtime
531
795
 
532
796
  `POST /apps` creates the app's table, records it, and adds it to the live
533
- allowlist — no restart, no config edit. It requires an **admin** key
797
+ allowlist, with no restart and no config edit. It requires an **admin** key
534
798
  (`ADMIN_API_KEYS`), which is a separate tier: a read key cannot provision, and
535
799
  an admin key cannot read analytics.
536
800
 
@@ -552,15 +816,15 @@ of letters, digits, underscore, and hyphen, and may not start with an underscore
552
816
 
553
817
  Two independent limits apply to writes:
554
818
 
555
- - `RATE_LIMIT_MAX` — per client IP. The single-abuser backstop.
556
- - `APP_RATE_LIMIT_MAX` — per `appId`. Stops one tenant consuming the budget
819
+ - `RATE_LIMIT_MAX`: per client IP. The single-abuser backstop.
820
+ - `APP_RATE_LIMIT_MAX`: per `appId`. Stops one tenant consuming the budget
557
821
  everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
558
822
  be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
559
823
 
560
824
  ### What is still yours to build
561
825
 
562
826
  Tenancy here is data isolation and quota, not a billing system. There is no
563
- usage metering, no plan enforcement, and no self-serve signup flow — `POST /apps`
827
+ usage metering, no plan enforcement, and no self-serve signup flow: `POST /apps`
564
828
  is an admin action you would call from your own onboarding code.
565
829
 
566
830
  ## Deployment Modes
@@ -596,6 +860,7 @@ const dbManager = new DatabaseManager({
596
860
  user: process.env.DB_USER,
597
861
  password: process.env.DB_PASSWORD,
598
862
  });
863
+ // Connects, and brings these apps' tables up to the current schema.
599
864
  await dbManager.initialize(['blog']);
600
865
 
601
866
  app.use('/analytics', createAnalyticsRouter({
@@ -617,48 +882,117 @@ app.use('/analytics', createAnalyticsRouter({
617
882
  }));
618
883
  ```
619
884
 
620
- Endpoints then live under the prefix — `POST /analytics/event`,
885
+ Endpoints then live under the prefix: `POST /analytics/event`,
621
886
  `GET /analytics/stats/blog`, and so on.
622
887
 
623
888
  Two things the host application owns in this mode, because the router does not
624
889
  install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
625
- `app.set('trust proxy', <hop count>)` — never `true`, or callers can forge
890
+ `app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
626
891
  their own IP through `X-Forwarded-For`.
627
892
 
893
+ #### Adding the admin UI
894
+
895
+ The [admin UI](#admin-ui) mounts the same way, at any path. Mount it before
896
+ your CORS middleware: it is same-origin only and must never carry the CORS
897
+ headers your tracked sites need. Its session cookie is scoped to the path you
898
+ choose, and it refuses a password shorter than 16 characters.
899
+
900
+ ```js
901
+ const { createAdminRouter, startRetention } = require('@harshankur/viewcounter');
902
+
903
+ const allowed = { appId: ['blog'], deviceSize: ['small', 'medium', 'large'], origins: {} };
904
+
905
+ app.use('/admin', createAdminRouter({
906
+ adminRepo: dbManager.admin,
907
+ logRepo: dbManager.logs,
908
+ config: {
909
+ allowed,
910
+ // The retention periods are shown in the UI; pass the ones you schedule below.
911
+ admin: { password: process.env.ADMIN_PASSWORD, trashRetentionDays: 30, viewLogRetentionDays: 90 },
912
+ server: { isProduction: process.env.NODE_ENV === 'production' },
913
+ },
914
+ }));
915
+
916
+ // Hourly: erases trashed views past their retention, and removes view-log
917
+ // entries past theirs (0 for either keeps it). Returns a function that stops it.
918
+ const stopRetention = startRetention({
919
+ adminRepo: dbManager.admin,
920
+ logRepo: dbManager.logs,
921
+ getAppIds: () => allowed.appId,
922
+ trashDays: 30,
923
+ viewLogDays: 90,
924
+ });
925
+ ```
926
+
628
927
  ### 3. Browser client
629
928
 
630
- There is no published client package yet; the snippets below are the
631
- integration surface. See [Client-Side Integration](#client-side-integration).
929
+ The server serves its own tracker script at `/tracker.js`. See
930
+ [Client-Side Integration](#client-side-integration).
632
931
 
633
932
  ## Client-Side Integration
634
933
 
635
- ### Basic Tracking
934
+ ### The tracker script
935
+
936
+ One tag, anywhere in the page:
937
+
636
938
  ```html
637
- <script>
638
- // Track page view
639
- fetch('https://your-server.com/registerView?appId=blog&deviceSize=medium');
640
- </script>
939
+ <script defer src="https://your-server.com/tracker.js" data-app="blog"
940
+ data-hosts="blog.example.com"></script>
641
941
  ```
642
942
 
643
- ### Enhanced Tracking
943
+ It records a view of each page, including page changes in single-page apps
944
+ (`history.pushState`, `replaceState`, and the back button, each referred by
945
+ the page it left); how long each page was visible and how far it was
946
+ scrolled; clicks on links to other sites (the other site's hostname only);
947
+ clicks on downloads (the file name only); and the landing URL's campaign tags.
948
+ It stores nothing on the device and sends no identifier, and it skips
949
+ automated browsers.
950
+
951
+ | Attribute | Default | Meaning |
952
+ |---|---|---|
953
+ | `data-app` | required | The app ID the views belong to |
954
+ | `data-hosts` | every host | Only track on these hostnames, comma-separated, so development servers and previews stay out of the data |
955
+ | `data-spa` | `true` | Treat history changes as page views |
956
+ | `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
957
+ | `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
958
+ | `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
959
+
960
+ Custom events: `window.viewcounter.track('signup', { plan: 'pro' })`.
961
+
962
+ For it to reach the server:
963
+
964
+ - the site's origin is in `CORS_ORIGINS`, and, if the app is bound to its
965
+ sites, registered for the app;
966
+ - with a Content Security Policy on the site, `script-src` and `connect-src`
967
+ allow the ViewCounter server.
968
+
969
+ Check the admin's **Tracking log** after adding it: every request shows up
970
+ there, recorded or not, with the reason when it was refused. A site missing
971
+ from `CORS_ORIGINS` shows up as "site not allowed: CORS_ORIGINS", counted from
972
+ the browser's preflight, since the request itself never arrives.
973
+
974
+ ### Without the script
975
+
976
+ The same requests by hand. Keep it this way round: nothing stored on the
977
+ device, no identifier generated in the browser.
978
+
644
979
  ```javascript
645
- // Generate a session ID (store in sessionStorage).
646
- // Use crypto.randomUUID(), not Math.random(): Math.random() is not a CSPRNG,
647
- // its output is short and predictable, and collisions merge two visitors'
648
- // sessions into one.
649
- const sessionId = sessionStorage.getItem('sessionId') || crypto.randomUUID();
650
- sessionStorage.setItem('sessionId', sessionId);
651
-
652
- // Track page view with full context
653
- fetch(`https://your-server.com/registerView?` + new URLSearchParams({
980
+ const params = new URLSearchParams({
654
981
  appId: 'blog',
655
- deviceSize: window.innerWidth < 768 ? 'small' :
656
- window.innerWidth < 1200 ? 'medium' : 'large',
657
- page: window.location.pathname,
982
+ deviceSize: innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large',
983
+ page: location.pathname,
658
984
  title: document.title,
659
985
  referrer: document.referrer,
660
- sessionId: sessionId
661
- }));
986
+ });
987
+ // Only the campaign tags from the query string.
988
+ for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
989
+ const value = new URLSearchParams(location.search).get(key);
990
+ if (value) params.set(key, value);
991
+ }
992
+ const { id } = await fetch(`https://your-server.com/registerView?${params}`).then((r) => r.json());
993
+ // Later, when the page is hidden: how long it was visible, and how far scrolled.
994
+ navigator.sendBeacon('https://your-server.com/engage',
995
+ new Blob([JSON.stringify({ appId: 'blog', id, ms: 42000, scroll: 80 })], { type: 'text/plain' }));
662
996
  ```
663
997
 
664
998
  ### Track Custom Events
@@ -671,7 +1005,6 @@ async function trackEvent(eventType, eventData) {
671
1005
  appId: 'blog',
672
1006
  eventType,
673
1007
  eventData,
674
- sessionId: sessionStorage.getItem('sessionId'),
675
1008
  page: window.location.pathname
676
1009
  })
677
1010
  });
@@ -699,8 +1032,15 @@ npm run test:persist
699
1032
 
700
1033
  # Run tests for CI/CD (no report generation)
701
1034
  npm run test:ci
1035
+
1036
+ # Run only the admin UI tests in a real browser (Playwright)
1037
+ npx playwright install chromium # once
1038
+ npm run test:ui
702
1039
  ```
703
1040
 
1041
+ `npm test` includes the Playwright suite, so run `npx playwright install
1042
+ chromium` once before the first run.
1043
+
704
1044
  ### Test Database
705
1045
 
706
1046
  **Automatic Management:**
@@ -770,7 +1110,7 @@ All endpoints are tested with:
770
1110
 
771
1111
  ## Releasing
772
1112
 
773
- Publishing to npm is a manual, deliberate step — an npm version number can never
1113
+ Publishing to npm is a manual, deliberate step: an npm version number can never
774
1114
  be reused, so it is not wired to run on merge.
775
1115
 
776
1116
  ```bash