@harshankur/viewcounter 3.0.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 ADDED
@@ -0,0 +1,797 @@
1
+ # ViewCounter
2
+
3
+ [![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-blueviolet)](https://harshankur.github.io/viewcounter/)
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-264%20passing-success)](TEST_REPORT.md)
7
+
8
+ A comprehensive Node.js/Express analytics server for tracking website views with MySQL storage, featuring auto-database creation, advanced tracking, and rich analytics.
9
+
10
+ ## 📖 Documentation
11
+ Visit our [Interactive Documentation](https://harshankur.github.io/viewcounter/) for detailed API specifications, debugging tips, and integration guides.
12
+
13
+ ## 🛡️ GDPR Compliant & Privacy-First
14
+ **100% GDPR Compliant By Design.** This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
15
+ - **Zero Cookies**: No cookies, no local storage, and no consent banners required.
16
+ - **Data Sovereignty**: You own your data. Analytics never leave your private infrastructure.
17
+ - **Minimal Collection**: Tracks only what is necessary (Country, Browser, OS, Page Path).
18
+
19
+ ### 🔄 Data Privacy Lifecycle
20
+ ```mermaid
21
+ graph LR
22
+ A[Visitor Request] --> B{Privacy Filter}
23
+ B -->|Transient| C[Geo-lookup]
24
+ B -->|Transient| D[Keyed with server secret]
25
+ C --> E[Masked IP: 1.2.3.0]
26
+ D --> F[HMAC-SHA256, rotating]
27
+ E --> G[(MySQL Database)]
28
+ F --> G
29
+ B -.->|Discarded| H[Raw IP Address]
30
+ style H fill:#f96,stroke:#333,stroke-width:2px
31
+ ```
32
+
33
+ ### 🧬 What happens to the IP?
34
+ We believe in total transparency regarding your visitors' data:
35
+ 1. **Transient Use Only**: The raw IP address is used only in memory, for the country lookup and for deriving the visitor hash. It is never written to the database, and log lines record the *masked* address.
36
+ 2. **Immediate Masking**: Before being saved, the IP is masked (IPv4 last octet zeroed; IPv6 interface identifier zeroed).
37
+ 3. **Keyed, Not Just Hashed**: The visitor identifier is an HMAC-SHA-256 keyed with a 32-byte server secret generated on first run and stored at mode `0600`. This matters: an *unkeyed* hash of an IP is reversible by exhausting the 2^32 IPv4 space, which takes about an hour on one CPU core. Without the secret, that search is infeasible.
38
+ 4. **Rotating**: The hash also mixes in a time window (`UNIQUE_VISITOR_WINDOW_HOURS`), so the same visitor hashes differently after each window and their visits cannot be linked over time.
39
+ 5. **Automated Guards**: [`tests/privacyFailSafe.test.js`](tests/privacyFailSafe.test.js) asserts that no raw IP or User-Agent reaches either the bound parameters *or* the SQL text of any statement, and that the hash is genuinely keyed. CI runs it on every push, so a change that started storing raw IPs would fail the build.
40
+
41
+ ## ✨ Features
42
+
43
+ ### Core Capabilities
44
+ - 🔒 **Security**: Prepared statements, rate limiting, Helmet.js, input validation
45
+ - ⚡ **Performance**: Connection pooling with mysql2, duplicate prevention
46
+ - 🗄️ **Flexible Database**: Connect to existing DB or auto-create schema
47
+ - 🛠️ **Easy Setup**: Interactive CLI wizard with config detection
48
+ - 🏥 **Production-Ready**: Health checks, graceful shutdown, structured logging
49
+
50
+ ### Advanced Tracking
51
+ - 📍 **Page Tracking**: Track specific pages/paths, not just app-level
52
+ - 🔗 **Referrer Analysis**: Automatic source categorization (search, social, email, campaign, referral, direct)
53
+ - 🖥️ **User Agent Parsing**: Browser, OS, and device type detection
54
+ - 👤 **Session Tracking**: Group views by user session
55
+ - 🎯 **Custom Events**: Track button clicks, form submissions, etc.
56
+ - 📊 **Time-Based Analytics**: Hourly, daily, and weekly trends
57
+
58
+ ## Quick Start
59
+
60
+ **Requires Node 24 or newer** and a reachable MySQL 8 (or MariaDB 11) instance.
61
+ Only the current Node LTS is supported — no matrix of older runtimes to
62
+ maintain.
63
+
64
+ ### 1. Install Dependencies
65
+ ```bash
66
+ npm install
67
+ ```
68
+
69
+ ### 2. Run Setup Wizard
70
+ ```bash
71
+ npm run setup
72
+ ```
73
+
74
+ The wizard will:
75
+ - Detect existing configuration (if any)
76
+ - Guide you through database setup (connect vs. create mode)
77
+ - Configure allowed app IDs and device sizes
78
+ - Optionally create `.env` file
79
+
80
+ ### 3. Start Server
81
+ ```bash
82
+ npm start
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ ### Database Modes
88
+
89
+ **Connect Mode** (default): Use existing database
90
+ ```json
91
+ // dbInfo.json
92
+ {
93
+ "mode": "connect",
94
+ "host": "127.0.0.1",
95
+ "database": "viewcounterdb",
96
+ "user": "root",
97
+ "password": "your_password"
98
+ }
99
+ ```
100
+
101
+ **Create Mode**: Auto-create database and tables
102
+ ```json
103
+ // dbInfo.json
104
+ {
105
+ "mode": "create",
106
+ "host": "127.0.0.1",
107
+ "database": "viewcounterdb",
108
+ "user": "root",
109
+ "password": "your_password"
110
+ }
111
+ ```
112
+
113
+ ### Allowed Values
114
+ ```json
115
+ // allowed.json
116
+ {
117
+ "appId": ["blog", "portfolio"],
118
+ "deviceSize": ["small", "medium", "large"]
119
+ }
120
+ ```
121
+
122
+ ### Environment Variables
123
+ See [`.env.example`](.env.example) for the full surface with prose on each one.
124
+
125
+ **Required in production** — the server refuses to start without these rather
126
+ than running on a guessable default:
127
+ - `DB_USER` / `DB_PASSWORD`: refuses to boot while still `root` with an empty password
128
+ - `DB_NAME`: database to write into
129
+ - `ALLOWED_APP_IDS` (or `allowed.json`): the placeholder `example_app` is rejected
130
+ - `CORS_ORIGINS`: browser origins allowed to call the write endpoints
131
+
132
+ **Recommended**:
133
+ - `READ_API_KEYS`: comma-separated keys for the analytics read endpoints. Unset means the read API is disabled.
134
+ - `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.
135
+ - `VISITOR_SECRET_PATH` / `VISITOR_SECRET`: where the visitor-hash secret lives, or the value itself.
136
+
137
+ **Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
138
+ `RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`.
139
+
140
+ ## API Endpoints
141
+
142
+ ### 📊 Tracking
143
+
144
+ #### Register View (Enhanced)
145
+ ```bash
146
+ # Basic (backward compatible)
147
+ GET /registerView?appId=blog&deviceSize=medium
148
+
149
+ # Enhanced with page tracking
150
+ GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&title=My%20Post
151
+
152
+ # With referrer and session
153
+ GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&sessionId=abc123
154
+ ```
155
+
156
+ **Automatic tracking:**
157
+ - ✅ IP address and geolocation
158
+ - ✅ Browser, OS, device type (from User-Agent)
159
+ - ✅ Referrer domain and source type
160
+ - ✅ Duplicate prevention (configurable window)
161
+
162
+ **Response**:
163
+ ```json
164
+ {"message": "Success!", "duplicate": false}
165
+ ```
166
+
167
+ #### Track Custom Event
168
+ ```bash
169
+ POST /event
170
+ Content-Type: application/json
171
+
172
+ {
173
+ "appId": "blog",
174
+ "eventType": "button_click",
175
+ "eventData": {"button": "subscribe", "location": "header"},
176
+ "sessionId": "abc123",
177
+ "page": "/blog/my-post"
178
+ }
179
+ ```
180
+
181
+ ### 📈 Analytics
182
+
183
+ > **These endpoints require authentication.** They return your analytics data,
184
+ > so every one of them expects a valid key in the `x-api-key` header. Configure
185
+ > keys via `READ_API_KEYS` (comma-separated, minimum 32 characters each). With
186
+ > none configured the read API returns `503` — it fails closed rather than
187
+ > serving your data to anyone who asks.
188
+ >
189
+ > ```bash
190
+ > curl -H "x-api-key: $VIEWCOUNTER_KEY" https://your-server.com/stats/blog
191
+ > ```
192
+ >
193
+ > The tracking endpoints above stay public by design: a browser on your site has
194
+ > to be able to reach them. They are bounded by validation, rate limiting, and
195
+ > per-app origin binding instead.
196
+
197
+
198
+ #### Get Statistics
199
+ ```bash
200
+ GET /stats/:appId
201
+ ```
202
+ **Response**:
203
+ ```json
204
+ {
205
+ "appId": "blog",
206
+ "stats": {
207
+ "total": 1523,
208
+ "uniqueVisitors": 892,
209
+ "last24Hours": 47,
210
+ "byCountry": [{"country": "US", "count": 423}],
211
+ "byDevice": [{"devicesize": "medium", "count": 789}]
212
+ }
213
+ }
214
+ ```
215
+
216
+ #### Get Trends
217
+ ```bash
218
+ # Daily trends for last 30 days
219
+ GET /trends/:appId?period=daily&days=30
220
+
221
+ # Hourly trends for last 7 days
222
+ GET /trends/:appId?period=hourly&days=7
223
+
224
+ # Weekly trends for last 12 weeks
225
+ GET /trends/:appId?period=weekly&days=84
226
+ ```
227
+
228
+ **Response**:
229
+ ```json
230
+ {
231
+ "appId": "blog",
232
+ "period": "daily",
233
+ "days": 30,
234
+ "trends": [
235
+ {"period": "2026-01-01", "count": 45},
236
+ {"period": "2026-01-02", "count": 52}
237
+ ]
238
+ }
239
+ ```
240
+
241
+ #### Get Referrer Statistics
242
+ ```bash
243
+ GET /referrers/:appId?limit=20
244
+ ```
245
+
246
+ **Response**:
247
+ ```json
248
+ {
249
+ "appId": "blog",
250
+ "bySource": [
251
+ {"source_type": "search", "count": 450},
252
+ {"source_type": "social", "count": 230},
253
+ {"source_type": "direct", "count": 180}
254
+ ],
255
+ "byDomain": [
256
+ {"referrer_domain": "google.com", "count": 320},
257
+ {"referrer_domain": "twitter.com", "count": 150}
258
+ ]
259
+ }
260
+ ```
261
+
262
+ #### Get Browser/OS Statistics
263
+ ```bash
264
+ GET /browsers/:appId
265
+ ```
266
+
267
+ **Response**:
268
+ ```json
269
+ {
270
+ "appId": "blog",
271
+ "byBrowser": [
272
+ {"browser": "Chrome", "count": 650},
273
+ {"browser": "Safari", "count": 320}
274
+ ],
275
+ "byOS": [
276
+ {"os": "Windows", "count": 550},
277
+ {"os": "Mac OS", "count": 380}
278
+ ],
279
+ "byDeviceType": [
280
+ {"device_type": "desktop", "count": 890},
281
+ {"device_type": "mobile", "count": 450}
282
+ ]
283
+ }
284
+ ```
285
+
286
+ #### Get Page Statistics
287
+ ```bash
288
+ GET /pages/:appId?limit=20
289
+ ```
290
+
291
+ **Response**:
292
+ ```json
293
+ {
294
+ "appId": "blog",
295
+ "pages": [
296
+ {"page_path": "/blog/post-1", "page_title": "My First Post", "views": 234},
297
+ {"page_path": "/blog/post-2", "page_title": "Second Post", "views": 189}
298
+ ]
299
+ }
300
+ ```
301
+
302
+ #### Get Session Details
303
+ ```bash
304
+ GET /sessions/:appId/:sessionId
305
+ ```
306
+
307
+ **Response**:
308
+ ```json
309
+ {
310
+ "appId": "blog",
311
+ "sessionId": "abc123",
312
+ "events": [
313
+ {
314
+ "id": 1,
315
+ "event_type": "pageview",
316
+ "page_path": "/blog/post-1",
317
+ "timestamp": "2026-01-09T21:30:00.000Z"
318
+ },
319
+ {
320
+ "id": 2,
321
+ "event_type": "button_click",
322
+ "event_data": {"button": "subscribe"},
323
+ "timestamp": "2026-01-09T21:31:15.000Z"
324
+ }
325
+ ],
326
+ "count": 2
327
+ }
328
+ ```
329
+
330
+ #### Get Recent Views
331
+ ```bash
332
+ GET /views/:appId?limit=10&offset=0
333
+ ```
334
+
335
+ #### List Apps
336
+ ```bash
337
+ GET /apps # requires x-api-key; filtered to the key's scope
338
+ ```
339
+
340
+ #### Register an App
341
+ ```bash
342
+ POST /apps # requires an ADMIN x-api-key
343
+ Content-Type: application/json
344
+
345
+ { "appId": "newcustomer", "origins": ["https://newcustomer.example"] }
346
+ ```
347
+ Creates the table and adds the app to the live allowlist without a restart.
348
+
349
+ #### Health Check
350
+ ```bash
351
+ GET /health # public; reports liveness only
352
+ ```
353
+
354
+ ## Deployment
355
+
356
+ ### Production with nohup
357
+ ```bash
358
+ nohup node index.js > stdout.log &
359
+ # Kill with: kill <pid>
360
+ ```
361
+
362
+ ### Environment Variables
363
+ Set `NODE_ENV=production` to hide error details in API responses.
364
+
365
+ ## What Gets Tracked?
366
+
367
+ For each view/event, the system automatically captures:
368
+
369
+ | Field | Source | Description |
370
+ |-------|--------|-------------|
371
+ | **IP Address** | Request | Visitor IP |
372
+ | **Country** | GeoIP lookup | 2-letter country code |
373
+ | **Timestamp** | Server | When the event occurred |
374
+ | **Device Size** | Query param | small, medium, large |
375
+ | **Page Path** | Query param (optional) | e.g., `/blog/my-post` |
376
+ | **Page Title** | Query param (optional) | e.g., "My Blog Post" |
377
+ | **Referrer** | Header/query (optional) | Full referrer URL |
378
+ | **Referrer Domain** | Parsed | e.g., `google.com` |
379
+ | **Source Type** | Parsed | search, social, email, campaign, referral, direct |
380
+ | **Browser** | User-Agent | e.g., Chrome, Safari, Firefox |
381
+ | **Browser Version** | User-Agent | e.g., 120.0 |
382
+ | **OS** | User-Agent | e.g., Windows, Mac OS, Linux |
383
+ | **OS Version** | User-Agent | e.g., 10, 14.2 |
384
+ | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console |
385
+ | **Session ID** | Query param (optional) | Group events by session |
386
+ | **Event Type** | Query param/body | pageview, click, submit, etc. |
387
+ | **Event Data** | Body (optional) | Custom JSON data |
388
+
389
+ ## Understanding `UNIQUE_VISITOR_WINDOW_HOURS`
390
+
391
+ This setting prevents counting the same visitor multiple times within a time window.
392
+
393
+ **How it works:**
394
+ - When a view is registered, the system checks if the same IP has visited within the last X hours
395
+ - If yes: Returns `{duplicate: true}` (doesn't count again)
396
+ - If no: Inserts new view
397
+
398
+ **Examples:**
399
+ - `24` (default): Same IP counts as 1 view per day
400
+ - `0`: Disable duplicate prevention (count every request)
401
+ - `168`: Same IP counts as 1 view per week
402
+
403
+ **Note:** Only applies to `pageview` events, not custom events.
404
+
405
+ ### 🛡️ Privacy Guardrails (Fail-Safe)
406
+ 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)):
407
+ - **Query Interception**: Every single SQL `INSERT` is intercepted during tests.
408
+ - **Regex Scanning**: We scan all query parameters against raw IP patterns (IPv4 and IPv6).
409
+ - **Hard Enforcement**: If the system ever attempts to save an unmasked IP, the test suite immediately fails, preventing accidental privacy regressions.
410
+
411
+ This makes ViewCounter not just "Privacy-First" by design, but **Privacy-Guaranteed** by automation.
412
+
413
+ - [x] Implement IP masking utility
414
+ - [x] Implement transient hashing for uniqueness
415
+ - [x] Update `DatabaseManager` to use hashes/masked IPs
416
+ - [x] Update `db/schema.sql` (column renaming/clarification)
417
+ - [x] Remove "IP Address" references from docs/README
418
+ - [x] Update documentation with "How it works" privacy section
419
+ - [x] Update and verify tests
420
+
421
+ ## Security Features
422
+
423
+ **Trust model.** The two write endpoints (`/registerView`, `/event`) are public
424
+ because a browser on your site must be able to reach them. Everything that
425
+ *reads* analytics is authenticated.
426
+
427
+ - ✅ **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.
428
+ - ✅ **Separate admin tier** — provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
429
+ - ✅ **Per-tenant rate limits** — an `appId`-keyed budget alongside the per-IP limit.
430
+ - ✅ **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.
431
+ - ✅ **SQL injection prevention** — every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
432
+ - ✅ **Explicit CORS allowlist** — no wildcard, and writes can be bound to registered origins per app.
433
+ - ✅ **Proxy-aware IP derivation** — client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
434
+ - ✅ **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.
435
+ - ✅ **Resource guards** — finite pool queue, per-statement timeout, rate limiting.
436
+ - ✅ **No error leakage** — failures return a request id; the detail goes only to the server log.
437
+ - ✅ **Security headers** (Helmet.js) and `Cache-Control: no-store` on all analytics responses.
438
+ - ✅ **Fail-fast config validation** — insecure defaults stop the boot rather than being silently accepted.
439
+ - ✅ **Adversarial regression suite** — [`tests/security.test.js`](tests/security.test.js) covers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
440
+
441
+ Report a vulnerability through [private advisory reporting](https://github.com/harshankur/viewcounter/security/advisories/new), not a public issue. See [SECURITY.md](SECURITY.md).
442
+
443
+ ## Usage Patterns
444
+
445
+ ### The deciding question: who holds the visitor's IP?
446
+
447
+ This determines whether an integration works or silently produces garbage.
448
+
449
+ **The browser calls ViewCounter directly.** It sees the real visitor IP, so
450
+ masking, geolocation, and the visitor hash all work. This is the intended path.
451
+
452
+ **Your server calls on the visitor's behalf** (SSR, a proxy route, a backend
453
+ hook). ViewCounter sees *your server's* address, so every visitor hashes
454
+ identically: unique visitors collapses to 1 and geo reports your datacenter
455
+ forever. Total views still count. If you must do this, forward the real address
456
+ and tell ViewCounter to believe you:
457
+
458
+ ```
459
+ X-Forwarded-For: <real visitor IP> # set by your code
460
+ TRUST_PROXY=1 # otherwise the header is ignored
461
+ ```
462
+
463
+ **A process with no visitor at all** (cron, CLI, worker, webhook) should use
464
+ `POST /event`. Custom events are never deduplicated, so "unique visitors" is
465
+ simply not a meaningful column for those rows.
466
+
467
+ ### Self-hosted blogs
468
+
469
+ One snippet in the layout every page includes.
470
+
471
+ | Platform | Where it goes |
472
+ |---|---|
473
+ | Hugo, Jekyll, Eleventy, Astro | `baseof.html` / `_layouts/default.html` / base layout |
474
+ | Ghost | Settings → Code injection → Site Footer |
475
+ | WordPress | `wp_footer` hook in the theme, or a small plugin |
476
+ | Docusaurus, MkDocs | theme footer partial |
477
+ | Next.js, Nuxt, SvelteKit | root layout, **plus a router hook** |
478
+
479
+ Static generators are the easy case: every navigation is a real page load, so
480
+ one fetch in the layout is complete coverage.
481
+
482
+ **SPAs are the trap.** Client-side routing fires no page load, so you record the
483
+ entry page and nothing else. Track again on route change:
484
+
485
+ ```js
486
+ router.afterEach(() => track()); // Vue / Nuxt
487
+ useEffect(() => track(), [pathname]); // Next.js app router
488
+ ```
489
+
490
+ Run one instance for all your sites — one `appId` each, each with its own table
491
+ and its own origin list.
492
+
493
+ ## Multi-Tenancy
494
+
495
+ Each `appId` is a tenant: its own table, its own origin allowlist, its own
496
+ request budget, and its own read credentials.
497
+
498
+ **`appId` is not a secret.** It travels in a URL the browser fetches, so anyone
499
+ can read it from your page source. What stops a stranger writing into your table
500
+ is the `origins` list, not the ID being unguessable. Configure origins per app.
501
+
502
+ ### Scoped read keys
503
+
504
+ A key maps to the apps it may read. Keys in `READ_API_KEYS` are unscoped (they
505
+ read everything, which is what you want when all the apps are yours). Scoped
506
+ keys live in `allowed.json`:
507
+
508
+ ```json
509
+ {
510
+ "appId": ["acme", "globex"],
511
+ "origins": {
512
+ "acme": ["https://acme.example"],
513
+ "globex": ["https://globex.example"]
514
+ },
515
+ "apiKeys": {
516
+ "<32+ char key for acme>": ["acme"],
517
+ "<32+ char key for globex>": ["globex"],
518
+ "<32+ char key for you>": "*"
519
+ }
520
+ }
521
+ ```
522
+
523
+ Acme's key on `GET /stats/globex` returns **403**. `GET /apps` returns only the
524
+ apps in the presented key's scope, so the listing cannot be used to discover
525
+ which other tenants exist. A nonexistent app returns the same 403 as an
526
+ out-of-scope one, for the same reason.
527
+
528
+ ### Provisioning a tenant at runtime
529
+
530
+ `POST /apps` creates the app's table, records it, and adds it to the live
531
+ allowlist — no restart, no config edit. It requires an **admin** key
532
+ (`ADMIN_API_KEYS`), which is a separate tier: a read key cannot provision, and
533
+ an admin key cannot read analytics.
534
+
535
+ ```bash
536
+ curl -X POST https://your-server.com/apps \
537
+ -H "x-api-key: $VIEWCOUNTER_ADMIN_KEY" \
538
+ -H "Content-Type: application/json" \
539
+ -d '{"appId": "newcustomer", "origins": ["https://newcustomer.example"]}'
540
+ ```
541
+
542
+ Registered apps live in an `_apps` table and are reloaded on every boot, so they
543
+ survive restarts. Re-registering is idempotent.
544
+
545
+ The `appId` becomes a MySQL table name, so it is restricted to 1–64 characters
546
+ of letters, digits, underscore, and hyphen, and may not start with an underscore
547
+ (reserved for internal tables). Anything else is rejected with 422.
548
+
549
+ ### Per-tenant request budgets
550
+
551
+ Two independent limits apply to writes:
552
+
553
+ - `RATE_LIMIT_MAX` — per client IP. The single-abuser backstop.
554
+ - `APP_RATE_LIMIT_MAX` — per `appId`. Stops one tenant consuming the budget
555
+ everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
556
+ be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
557
+
558
+ ### What is still yours to build
559
+
560
+ Tenancy here is data isolation and quota, not a billing system. There is no
561
+ usage metering, no plan enforcement, and no self-serve signup flow — `POST /apps`
562
+ is an admin action you would call from your own onboarding code.
563
+
564
+ ## Deployment Modes
565
+
566
+ ViewCounter can run three ways. All three share the same route layer
567
+ ([`routes/analytics.js`](routes/analytics.js)), so behaviour is identical.
568
+
569
+ ### 1. Standalone server
570
+
571
+ The default. Runs its own Express app on its own port.
572
+
573
+ ```bash
574
+ npm start # listens on PORT (default 3030)
575
+ ```
576
+
577
+ ### 2. Mounted as Express middleware
578
+
579
+ Mount the router into an application you already have, under any path prefix.
580
+ Useful when you would rather not run and reverse-proxy a second service.
581
+
582
+ ```js
583
+ const express = require('express');
584
+ const { createAnalyticsRouter, DatabaseManager } = require('@harshankur/viewcounter');
585
+
586
+ const app = express();
587
+ app.use(express.json({ limit: '16kb' }));
588
+
589
+ const dbManager = new DatabaseManager({
590
+ mode: 'connect',
591
+ host: '127.0.0.1',
592
+ port: 3306,
593
+ database: 'viewcounterdb',
594
+ user: process.env.DB_USER,
595
+ password: process.env.DB_PASSWORD,
596
+ });
597
+ await dbManager.initialize(['blog']);
598
+
599
+ app.use('/analytics', createAnalyticsRouter({
600
+ dbManager,
601
+ config: {
602
+ allowed: { appId: ['blog'], deviceSize: ['small', 'medium', 'large'], origins: {} },
603
+ auth: {
604
+ // key -> the apps it may read, or '*' for all
605
+ readKeyScopes: { [process.env.VIEWCOUNTER_KEY]: '*' },
606
+ adminApiKeys: [],
607
+ },
608
+ privacy: { visitorSecret: process.env.VISITOR_SECRET },
609
+ server: {
610
+ uniqueVisitorWindowHours: 24,
611
+ // omit to disable the per-app write budget
612
+ rateLimit: { windowMs: 60000, perAppMax: 1000 },
613
+ },
614
+ },
615
+ }));
616
+ ```
617
+
618
+ Endpoints then live under the prefix — `POST /analytics/event`,
619
+ `GET /analytics/stats/blog`, and so on.
620
+
621
+ Two things the host application owns in this mode, because the router does not
622
+ install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
623
+ `app.set('trust proxy', <hop count>)` — never `true`, or callers can forge
624
+ their own IP through `X-Forwarded-For`.
625
+
626
+ ### 3. Browser client
627
+
628
+ There is no published client package yet; the snippets below are the
629
+ integration surface. See [Client-Side Integration](#client-side-integration).
630
+
631
+ ## Client-Side Integration
632
+
633
+ ### Basic Tracking
634
+ ```html
635
+ <script>
636
+ // Track page view
637
+ fetch('https://your-server.com/registerView?appId=blog&deviceSize=medium');
638
+ </script>
639
+ ```
640
+
641
+ ### Enhanced Tracking
642
+ ```javascript
643
+ // Generate a session ID (store in sessionStorage).
644
+ // Use crypto.randomUUID(), not Math.random(): Math.random() is not a CSPRNG,
645
+ // its output is short and predictable, and collisions merge two visitors'
646
+ // sessions into one.
647
+ const sessionId = sessionStorage.getItem('sessionId') || crypto.randomUUID();
648
+ sessionStorage.setItem('sessionId', sessionId);
649
+
650
+ // Track page view with full context
651
+ fetch(`https://your-server.com/registerView?` + new URLSearchParams({
652
+ appId: 'blog',
653
+ deviceSize: window.innerWidth < 768 ? 'small' :
654
+ window.innerWidth < 1200 ? 'medium' : 'large',
655
+ page: window.location.pathname,
656
+ title: document.title,
657
+ referrer: document.referrer,
658
+ sessionId: sessionId
659
+ }));
660
+ ```
661
+
662
+ ### Track Custom Events
663
+ ```javascript
664
+ async function trackEvent(eventType, eventData) {
665
+ await fetch('https://your-server.com/event', {
666
+ method: 'POST',
667
+ headers: {'Content-Type': 'application/json'},
668
+ body: JSON.stringify({
669
+ appId: 'blog',
670
+ eventType,
671
+ eventData,
672
+ sessionId: sessionStorage.getItem('sessionId'),
673
+ page: window.location.pathname
674
+ })
675
+ });
676
+ }
677
+
678
+ // Track button click
679
+ document.querySelector('#subscribe-btn').addEventListener('click', () => {
680
+ trackEvent('button_click', {button: 'subscribe', location: 'header'});
681
+ });
682
+ ```
683
+
684
+ ## Testing
685
+
686
+ ### Running Tests
687
+
688
+ ```bash
689
+ # Run all tests with coverage (auto-generates TEST_REPORT.md)
690
+ npm test
691
+
692
+ # Run tests in watch mode (for development)
693
+ npm run test:watch
694
+
695
+ # Run tests and persist database for inspection
696
+ npm run test:persist
697
+
698
+ # Run tests for CI/CD (no report generation)
699
+ npm run test:ci
700
+ ```
701
+
702
+ ### Test Database
703
+
704
+ **Automatic Management:**
705
+ - ✅ Creates fresh `viewcounterdb_test` database before each test run
706
+ - ✅ Populates with realistic test data
707
+ - ✅ Automatically cleaned up after tests complete
708
+
709
+ **Persist Database for Debugging:**
710
+ ```bash
711
+ # Keep test database after tests
712
+ npm run test:persist
713
+
714
+ # Or set environment variable
715
+ PERSIST_TEST_DB=true npm test
716
+ ```
717
+
718
+ When persisted, you can inspect the database:
719
+ ```sql
720
+ USE viewcounterdb_test;
721
+ SHOW TABLES;
722
+ SELECT * FROM test_app_1;
723
+ ```
724
+
725
+ To manually remove:
726
+ ```sql
727
+ DROP DATABASE viewcounterdb_test;
728
+ ```
729
+
730
+ ### Test Reports
731
+
732
+ **Automatically generated after every test run:**
733
+ - ✅ **Terminal output**: Immediate test results and coverage
734
+ - ✅ **TEST_REPORT.md**: Comprehensive markdown summary (auto-generated)
735
+ - ✅ **test-report.html**: Visual test results with dark theme
736
+ - ✅ **coverage/index.html**: Interactive code coverage report
737
+
738
+ All reports are created in the project root directory.
739
+
740
+ ### Test Coverage
741
+
742
+ The test suite includes:
743
+
744
+ #### Unit Tests
745
+ - ✅ **UserAgentParser**: Browser, OS, and device detection
746
+ - ✅ **ReferrerParser**: Traffic source categorization
747
+
748
+ #### Integration Tests
749
+ - ✅ **Health Check**: Server status monitoring
750
+ - ✅ **View Registration**: Basic and enhanced tracking
751
+ - ✅ **Custom Events**: Event tracking with metadata
752
+ - ✅ **Statistics**: Aggregated analytics
753
+ - ✅ **Trends**: Time-based analytics
754
+ - ✅ **Referrers**: Traffic source analysis
755
+ - ✅ **Browsers**: Browser/OS/device breakdown
756
+ - ✅ **Pages**: Page view statistics
757
+ - ✅ **Sessions**: Session journey tracking
758
+ - ✅ **Rate Limiting**: Request throttling
759
+
760
+ ### Test Scenarios
761
+
762
+ All endpoints are tested with:
763
+ - ✓ Valid inputs
764
+ - ✓ Invalid inputs
765
+ - ✓ Missing parameters
766
+ - ✓ Edge cases
767
+ - ✓ Security validation
768
+
769
+ ## Releasing
770
+
771
+ Publishing to npm is a manual, deliberate step — an npm version number can never
772
+ be reused, so it is not wired to run on merge.
773
+
774
+ ```bash
775
+ npm version patch|minor|major # bump package.json + CHANGELOG in one commit
776
+ git push # land the bump
777
+ gh workflow run release.yml # test, tag, publish with provenance, release
778
+ ```
779
+
780
+ The package is published as **`@harshankur/viewcounter`** (scoped). npm rejects
781
+ the unscoped `viewcounter` as too similar to the existing `view-counter`; a
782
+ scope is its own namespace, so the collision does not apply.
783
+
784
+ Authentication is OIDC via npm Trusted Publishing, so no long-lived token is
785
+ stored. **Except once:** npm cannot publish a package's first version over OIDC,
786
+ because a trusted publisher is configured on the package's settings page and
787
+ that page does not exist until the package does. For the first release only,
788
+ publish once locally or set an `NPM_TOKEN` secret, then configure the trusted
789
+ publisher (npmjs.com → package → Settings → Trusted Publisher → this repo and
790
+ `release.yml`) and delete the secret.
791
+
792
+ To publish automatically on every version bump instead, uncomment the `push:`
793
+ trigger in `.github/workflows/release.yml`.
794
+
795
+ ## License
796
+
797
+ [MIT](LICENSE) - Do whatever you want with this, just don't sue us.