@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
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "ViewCounter Admin",
3
+ "short_name": "ViewCounter",
4
+ "start_url": "./",
5
+ "icons": [
6
+ {
7
+ "src": "icon-192.png",
8
+ "sizes": "192x192",
9
+ "type": "image/png"
10
+ },
11
+ {
12
+ "src": "icon-512.png",
13
+ "sizes": "512x512",
14
+ "type": "image/png"
15
+ }
16
+ ],
17
+ "theme_color": "#0a0f0c",
18
+ "background_color": "#0a0f0c",
19
+ "display": "standalone"
20
+ }
package/config/index.js CHANGED
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  require('dotenv').config({ quiet: true });
5
5
 
6
6
  const {
7
+ ADMIN,
7
8
  DATABASE,
8
9
  INSECURE_DEFAULTS,
9
10
  NODE_ENV,
@@ -12,6 +13,7 @@ const {
12
13
  SERVER,
13
14
  } = require('../constants');
14
15
  const { filterValidAppIds } = require('../utils/appIdUtils');
16
+ const { parseDuration, formatDuration } = require('../utils/durationUtils');
15
17
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
16
18
  const { LogLevel } = require('../utils/logger');
17
19
 
@@ -70,7 +72,7 @@ function readJsonFile(filePath) {
70
72
  *
71
73
  * CONFIG.md §4: never a shallow spread of a nested object. The previous
72
74
  * implementation returned the parsed file verbatim, so a `dbInfo.json` missing
73
- * `host` produced `host: undefined` instead of falling back — and an
75
+ * `host` produced `host: undefined` instead of falling back, and an
74
76
  * `allowed.json` missing `appId` produced `undefined`, which then threw on
75
77
  * `.join()` at startup.
76
78
  *
@@ -96,13 +98,17 @@ class Config {
96
98
  this.allowed = this.loadAllowed();
97
99
  this.auth = this.loadAuthConfig();
98
100
  this.privacy = this.loadPrivacyConfig();
101
+ this.admin = this.loadAdminConfig();
102
+ // Optional: an .mmdb city database (DB-IP City Lite or GeoLite2 City)
103
+ // for each view's region and city. Without it, country only.
104
+ this.geo = { cityDatabase: this.env.GEOIP_CITY_DB || null };
99
105
  }
100
106
 
101
107
  /**
102
108
  * Server, logging, and proxy settings.
103
109
  *
104
110
  * `nodeEnv` defaults to production. It previously defaulted to
105
- * development, which is also what the setup wizard wrote into `.env` — and
111
+ * development, which is also what the setup wizard wrote into `.env`, and
106
112
  * ten route handlers echo raw database error text to the caller when the
107
113
  * environment is development. A deploy that forgot to set NODE_ENV leaked
108
114
  * table names and SQL fragments to anonymous callers.
@@ -207,7 +213,7 @@ class Config {
207
213
  *
208
214
  * Two sources, because they serve different deployments:
209
215
  * - `READ_API_KEYS` (env): unscoped keys that can read every app. This is
210
- * the single-operator case — all the apps are yours anyway.
216
+ * the single-operator case: all the apps are yours anyway.
211
217
  * - `apiKeys` in allowed.json: `{ "<key>": ["blog"] }`, scoped to named
212
218
  * apps. This is the multi-tenant case, where one customer's key must
213
219
  * not read another customer's analytics. Use `"*"` for an unscoped key.
@@ -273,7 +279,7 @@ class Config {
273
279
  // Resolved lazily, on first read rather than at construction.
274
280
  //
275
281
  // This module is imported by index.js, and index.js is the package
276
- // entry point — so an application that only wants to mount
282
+ // entry point, so an application that only wants to mount
277
283
  // createAnalyticsRouter would otherwise generate and persist a secret
278
284
  // purely as a side effect of `require('@harshankur/viewcounter')`, writing it into
279
285
  // node_modules where the next `npm ci` wipes it. Embedders supply their
@@ -293,6 +299,82 @@ class Config {
293
299
  };
294
300
  }
295
301
 
302
+ /**
303
+ * Admin UI credential and data-retention settings.
304
+ *
305
+ * `ADMIN_PASSWORD` is its own credential tier (SECURITY.md §3): it can
306
+ * read, edit, and delete every app's data, which neither a read key nor an
307
+ * ADMIN_API_KEYS provisioning key may do. It is read from the environment
308
+ * only, never from a config file, because it is a secret. The UI is
309
+ * disabled, failing closed, whenever it is absent.
310
+ *
311
+ * A password that is set but shorter than the minimum is kept (so
312
+ * validate() can refuse to boot on it in production) but never enables
313
+ * the UI.
314
+ *
315
+ * `TRASH_RETENTION_DAYS` bounds how long a soft-deleted view is kept before
316
+ * it is erased for good (GDPR Art. 5(1)(e), storage limitation). Zero keeps
317
+ * trash until an admin empties it by hand.
318
+ *
319
+ * `VIEW_LOG_RETENTION_DAYS` bounds the view register log, which gains a
320
+ * row for every accepted view. It holds no personal data, so this is about
321
+ * storage rather than law, but a log nobody bounds grows as large as all
322
+ * the app tables together. Zero keeps it forever. The admin log is never
323
+ * pruned: it is the record of who changed or erased what, and it grows only
324
+ * with admin activity.
325
+ *
326
+ * An out-of-range or unparseable retention falls back to the default.
327
+ *
328
+ * `ADMIN_SESSION_IDLE_TIMEOUT` and `ADMIN_SESSION_MAX_AGE` ("30m", "12h",
329
+ * "7d") bound how long a sign-in lasts: signed out after that long unused,
330
+ * and after that long regardless. Unlike a retention, a malformed timeout
331
+ * stops startup: silently using the default would give a typo like
332
+ * "30 min" a week-long session.
333
+ */
334
+ loadAdminConfig() {
335
+ const password = this.env.ADMIN_PASSWORD || '';
336
+ const longEnough = password.length >= ADMIN.MIN_PASSWORD_LENGTH;
337
+
338
+ const retention = (raw, fallback, max) => {
339
+ const days = parseIntOr(raw, fallback);
340
+ return days >= 0 && days <= max ? days : fallback;
341
+ };
342
+
343
+ const duration = (field, fallback, min, max) => {
344
+ const raw = this.env[field];
345
+ if (raw === undefined || raw === '') return fallback;
346
+ const ms = parseDuration(raw);
347
+ if (ms === null || ms < min || ms > max) {
348
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
349
+ field,
350
+ reason: `must be a duration such as 30m, 12h, or 7d, from ${formatDuration(min)} to ${formatDuration(max)}`,
351
+ });
352
+ }
353
+ return ms;
354
+ };
355
+ const sessionIdleMs = duration('ADMIN_SESSION_IDLE_TIMEOUT',
356
+ ADMIN.SESSION_IDLE_TIMEOUT_MS, ADMIN.SESSION_IDLE_MIN_MS, ADMIN.SESSION_IDLE_MAX_MS);
357
+ const sessionMaxAgeMs = duration('ADMIN_SESSION_MAX_AGE',
358
+ ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS, ADMIN.SESSION_MAX_AGE_MIN_MS, ADMIN.SESSION_MAX_AGE_MAX_MS);
359
+ if (sessionIdleMs > sessionMaxAgeMs) {
360
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
361
+ field: 'ADMIN_SESSION_IDLE_TIMEOUT',
362
+ reason: `must not be longer than ADMIN_SESSION_MAX_AGE (${formatDuration(sessionMaxAgeMs)})`,
363
+ });
364
+ }
365
+
366
+ return {
367
+ password,
368
+ enabled: longEnough,
369
+ sessionIdleMs,
370
+ sessionMaxAgeMs,
371
+ trashRetentionDays: retention(
372
+ this.env.TRASH_RETENTION_DAYS, ADMIN.DEFAULT_TRASH_RETENTION_DAYS, ADMIN.MAX_TRASH_RETENTION_DAYS),
373
+ viewLogRetentionDays: retention(
374
+ this.env.VIEW_LOG_RETENTION_DAYS, ADMIN.DEFAULT_VIEW_LOG_RETENTION_DAYS, ADMIN.MAX_VIEW_LOG_RETENTION_DAYS),
375
+ };
376
+ }
377
+
296
378
  /**
297
379
  * Fail-fast startup validation (CONFIG.md §3, SECURITY.md §1).
298
380
  *
@@ -308,6 +390,8 @@ class Config {
308
390
  // persist it fails at startup rather than on its first request.
309
391
  void this.privacy.visitorSecret;
310
392
 
393
+ this.validateAdmin();
394
+
311
395
  if (!this.server.isProduction) {
312
396
  this.warnAboutDevelopmentDefaults();
313
397
  return this;
@@ -338,6 +422,40 @@ class Config {
338
422
  return this;
339
423
  }
340
424
 
425
+ /**
426
+ * Admin credential checks, shared by every environment.
427
+ *
428
+ * A password that is present but too short is a misconfiguration, not a
429
+ * choice, so production refuses to boot on it rather than silently leaving
430
+ * the UI off. Reusing an API key as the password collapses two tiers into
431
+ * one secret, which is worth a warning in any environment.
432
+ */
433
+ validateAdmin() {
434
+ const { password, enabled } = this.admin;
435
+
436
+ if (!password) {
437
+ logWarning(WarningType.ADMIN_UI_DISABLED);
438
+ return;
439
+ }
440
+
441
+ if (!enabled && this.server.isProduction) {
442
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
443
+ field: 'ADMIN_PASSWORD',
444
+ reason: `must be at least ${ADMIN.MIN_PASSWORD_LENGTH} characters`,
445
+ });
446
+ }
447
+
448
+ if (!enabled) {
449
+ logWarning(WarningType.ADMIN_UI_DISABLED);
450
+ return;
451
+ }
452
+
453
+ const apiKeys = [...Object.keys(this.auth.readKeyScopes), ...this.auth.adminApiKeys];
454
+ if (apiKeys.includes(password)) {
455
+ logWarning(WarningType.ADMIN_PASSWORD_REUSED);
456
+ }
457
+ }
458
+
341
459
  /** Surface the same problems as warnings outside production. */
342
460
  warnAboutDevelopmentDefaults() {
343
461
  if (Object.keys(this.auth.readKeyScopes).length === 0) {
package/constants.js CHANGED
@@ -14,9 +14,11 @@ const APP_SLUG = 'viewcounter';
14
14
 
15
15
  const HTTP_STATUS = {
16
16
  OK: 200,
17
+ NO_CONTENT: 204,
17
18
  BAD_REQUEST: 400,
18
19
  UNAUTHORIZED: 401,
19
20
  FORBIDDEN: 403,
21
+ NOT_FOUND: 404,
20
22
  UNPROCESSABLE_ENTITY: 422,
21
23
  TOO_MANY_REQUESTS: 429,
22
24
  INTERNAL_SERVER_ERROR: 500,
@@ -46,6 +48,18 @@ const FIELD_MAX_LENGTH = {
46
48
  DEVICE_TYPE: 20,
47
49
  SESSION_ID: 64,
48
50
  EVENT_TYPE: 50,
51
+ /** Free-text admin annotation on a single view. */
52
+ NOTE: 1000,
53
+ /** CHAR(36): the canonical textual form of a UUID. */
54
+ UUID: 36,
55
+ /** The longest a DNS name can be. */
56
+ HOSTNAME: 253,
57
+ /** A primary language subtag: two or three letters, rarely up to eight. */
58
+ LANGUAGE: 8,
59
+ /** Each of utm_source, utm_medium, utm_campaign, utm_term, utm_content. */
60
+ UTM: 100,
61
+ REGION: 100,
62
+ CITY: 100,
49
63
  };
50
64
 
51
65
  /** Bounds for user-supplied pagination and range parameters. */
@@ -83,7 +97,9 @@ const DATABASE = {
83
97
  QUERY_TIMEOUT_MS: 5_000,
84
98
  CONNECT_TIMEOUT_MS: 10_000,
85
99
  DEFAULT_PORT: 3306,
86
- SCHEMA_VERSION: 'enhanced_schema_v3',
100
+ SCHEMA_VERSION: 'schema_v5',
101
+ /** Rows given a public_id per statement when backfilling an old table. */
102
+ BACKFILL_BATCH_SIZE: 500,
87
103
  };
88
104
 
89
105
  const SERVER = {
@@ -117,6 +133,296 @@ const PRIVACY = {
117
133
  MIN_ADMIN_KEY_LENGTH: 32,
118
134
  };
119
135
 
136
+ /**
137
+ * Admin UI and API.
138
+ *
139
+ * The admin tier is a separate credential from both read keys and
140
+ * ADMIN_API_KEYS (SECURITY.md §3): it can read, edit, and delete every app's
141
+ * data, which neither of the other tiers may do.
142
+ */
143
+ const ADMIN = {
144
+ /** Where the UI and its API are mounted. */
145
+ PATH_PREFIX: '/admin',
146
+ /** Relative to PATH_PREFIX. */
147
+ API_PATH: '/api',
148
+ SESSION_COOKIE: 'vc_admin_session',
149
+ CSRF_HEADER: 'x-csrf-token',
150
+ /** Long enough that the login rate limit makes guessing hopeless. */
151
+ MIN_PASSWORD_LENGTH: 16,
152
+ /** Longest submitted password even looked at; bounds the comparison cost. */
153
+ MAX_PASSWORD_INPUT_LENGTH: 1024,
154
+ SESSION_TOKEN_BYTES: 32,
155
+ CSRF_TOKEN_BYTES: 32,
156
+ /** Default for ADMIN_SESSION_IDLE_TIMEOUT: signed out after this long unused. */
157
+ SESSION_IDLE_TIMEOUT_MS: 7 * 24 * 60 * 60 * 1000,
158
+ /** Default for ADMIN_SESSION_MAX_AGE: signed out after this long regardless. */
159
+ SESSION_ABSOLUTE_TIMEOUT_MS: 30 * 24 * 60 * 60 * 1000,
160
+ SESSION_IDLE_MIN_MS: 5 * 60 * 1000,
161
+ SESSION_IDLE_MAX_MS: 90 * 24 * 60 * 60 * 1000,
162
+ SESSION_MAX_AGE_MIN_MS: 60 * 60 * 1000,
163
+ SESSION_MAX_AGE_MAX_MS: 365 * 24 * 60 * 60 * 1000,
164
+ /**
165
+ * A session's last-seen time is written at most this often, so an active
166
+ * admin costs one database write a minute rather than one per request.
167
+ */
168
+ SESSION_TOUCH_INTERVAL_MS: 60 * 1000,
169
+ /** Permanent erasure needs the password to have been entered this recently. */
170
+ REAUTH_WINDOW_MS: 15 * 60 * 1000,
171
+ /** Oldest sessions are evicted beyond this, bounding memory. */
172
+ MAX_SESSIONS: 50,
173
+ LOGIN_RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000,
174
+ /** Failed attempts per IP per window. Successful logins do not count. */
175
+ LOGIN_RATE_LIMIT_MAX: 5,
176
+ RATE_LIMIT_WINDOW_MS: 60 * 1000,
177
+ /** Requests per IP per window across the whole admin surface. */
178
+ RATE_LIMIT_MAX: 600,
179
+ /** Upper bound on the rows one batch operation may touch. */
180
+ MAX_BATCH_IDS: 500,
181
+ /**
182
+ * JSON body ceiling for the admin API. A full batch of MAX_BATCH_IDS
183
+ * UUIDs is about 20 kB on its own, above the public endpoints' limit.
184
+ */
185
+ MAX_BODY_BYTES: 64 * 1024,
186
+ PAGE_SIZES: [25, 50, 100],
187
+ PAGE_SIZE_DEFAULT: 50,
188
+ PAGE_MAX: 100_000,
189
+ SEARCH_MAX_LENGTH: 200,
190
+ DEFAULT_TRASH_RETENTION_DAYS: 30,
191
+ MAX_TRASH_RETENTION_DAYS: 3650,
192
+ DEFAULT_VIEW_LOG_RETENTION_DAYS: 90,
193
+ MAX_VIEW_LOG_RETENTION_DAYS: 3650,
194
+ /** Rows removed per statement when pruning the view log, so no delete holds locks for long. */
195
+ VIEW_LOG_PRUNE_BATCH_SIZE: 5000,
196
+ /** How often expired trash and old view-log entries are checked for. */
197
+ RETENTION_INTERVAL_MS: 60 * 60 * 1000,
198
+ /** The tracking log's summary covers this many recent hours. */
199
+ TRACKING_SUMMARY_HOURS: 24,
200
+ };
201
+
202
+ /**
203
+ * Date ranges an admin listing and its analysis can be limited to, mapped to
204
+ * a number of days. `all` has no lower bound. Only these values reach SQL.
205
+ */
206
+ const ADMIN_RANGE = {
207
+ DAY: '24h',
208
+ WEEK: '7d',
209
+ MONTH: '30d',
210
+ QUARTER: '90d',
211
+ YEAR: '1y',
212
+ ALL: 'all',
213
+ };
214
+
215
+ const ADMIN_RANGE_DAYS = {
216
+ [ADMIN_RANGE.DAY]: 1,
217
+ [ADMIN_RANGE.WEEK]: 7,
218
+ [ADMIN_RANGE.MONTH]: 30,
219
+ [ADMIN_RANGE.QUARTER]: 90,
220
+ [ADMIN_RANGE.YEAR]: 365,
221
+ [ADMIN_RANGE.ALL]: null,
222
+ };
223
+
224
+ /**
225
+ * Time-series bucket for the admin analysis, chosen from the span of the data
226
+ * actually in the filtered set, so a chart never has thousands of points.
227
+ */
228
+ const TREND_BUCKET = {
229
+ HOUR: 'hour',
230
+ DAY: 'day',
231
+ WEEK: 'week',
232
+ MONTH: 'month',
233
+ };
234
+
235
+ /** Largest span, in days, charted per day and per week. */
236
+ const TREND_BUCKET_MAX_DAYS = {
237
+ [TREND_BUCKET.HOUR]: 2,
238
+ [TREND_BUCKET.DAY]: 92,
239
+ [TREND_BUCKET.WEEK]: 731,
240
+ };
241
+
242
+ /** Rows per breakdown returned by the admin analysis; the rest is "other". */
243
+ const ANALYSIS_TOP_N = 10;
244
+
245
+ /** Bounds of the admin analysis beyond the breakdowns. */
246
+ const ANALYSIS = {
247
+ /**
248
+ * A visit ends after this long without a page view, the common 30-minute
249
+ * convention. Visits are read from the rotating visitor hash, so they
250
+ * never link a visitor across days.
251
+ */
252
+ VISIT_GAP_SECONDS: 30 * 60,
253
+ /** Page-to-page steps listed in the page flow. */
254
+ TRANSITIONS_TOP_N: 15,
255
+ /** The time-of-day heatmap reads at most the last year of the range. */
256
+ HEATMAP_MAX_DAYS: 365,
257
+ /** Most recent custom events whose properties are tallied. */
258
+ EVENT_PROPERTIES_SAMPLE: 5000,
259
+ /** Property values listed across all custom events. */
260
+ EVENT_PROPERTIES_TOP_N: 30,
261
+ /** "Right now" means the last few minutes; the live chart covers half an hour. */
262
+ REALTIME_VISITOR_MINUTES: 5,
263
+ REALTIME_CHART_MINUTES: 30,
264
+ };
265
+
266
+ /** Which rows an admin listing returns. */
267
+ const VIEW_STATUS = {
268
+ ACTIVE: 'active',
269
+ DELETED: 'deleted',
270
+ ALL: 'all',
271
+ };
272
+
273
+ /** Filter on whether an admin has edited a row's content. */
274
+ const MODIFIED_FILTER = {
275
+ ANY: 'any',
276
+ MODIFIED: 'modified',
277
+ UNMODIFIED: 'unmodified',
278
+ };
279
+
280
+ const SORT_ORDER = {
281
+ ASC: 'asc',
282
+ DESC: 'desc',
283
+ };
284
+
285
+ /**
286
+ * Columns an admin listing may be sorted by, mapped from the API name to the
287
+ * column. Sorting interpolates the column, so only these values can reach SQL.
288
+ */
289
+ const ADMIN_SORT_COLUMNS = {
290
+ timestamp: 'timestamp',
291
+ page: 'page_path',
292
+ country: 'country',
293
+ deviceSize: 'devicesize',
294
+ eventType: 'event_type',
295
+ source: 'source_type',
296
+ browser: 'browser',
297
+ os: 'os',
298
+ title: 'page_title',
299
+ hostname: 'hostname',
300
+ referrer: 'referrer_domain',
301
+ campaign: 'utm_campaign',
302
+ language: 'language',
303
+ engagedMs: 'engaged_ms',
304
+ modifiedAt: 'admin_modified_at',
305
+ deletedAt: 'deleted_at',
306
+ };
307
+
308
+ /**
309
+ * Content fields an admin may edit, mapped from the API name to the column.
310
+ *
311
+ * Deliberately excludes everything that records who or when: the masked IP,
312
+ * visitor hash, timestamp, country, and every User-Agent-derived field. An
313
+ * edit can correct what was viewed, never fabricate who viewed it or when.
314
+ * `referrerDomain` and `sourceType` are not editable directly; they are
315
+ * re-derived whenever `referrer` changes, so they can never disagree with it.
316
+ */
317
+ const EDITABLE_FIELDS = {
318
+ pagePath: 'page_path',
319
+ pageTitle: 'page_title',
320
+ referrer: 'referrer',
321
+ deviceSize: 'devicesize',
322
+ eventType: 'event_type',
323
+ eventData: 'event_data',
324
+ };
325
+
326
+ /** Every entry in the admin operation log is one of these. */
327
+ const ADMIN_ACTION = {
328
+ LOGIN_SUCCEEDED: 'login_succeeded',
329
+ LOGIN_FAILED: 'login_failed',
330
+ LOGOUT: 'logout',
331
+ VIEWS_EDITED: 'views_edited',
332
+ NOTE_SET: 'note_set',
333
+ NOTE_CLEARED: 'note_cleared',
334
+ VIEWS_DELETED: 'views_deleted',
335
+ VIEWS_RESTORED: 'views_restored',
336
+ VIEWS_PURGED: 'views_purged',
337
+ TRASH_AUTO_PURGED: 'trash_auto_purged',
338
+ VIEW_LOG_PRUNED: 'view_log_pruned',
339
+ /** The password entered again to allow a permanent erasure. */
340
+ PASSWORD_CONFIRMED: 'password_confirmed',
341
+ };
342
+
343
+ /**
344
+ * Machine-readable error codes returned by the admin API. The UI maps each one
345
+ * to a translated message, so no server-side English reaches the screen.
346
+ */
347
+ const ADMIN_ERROR_CODE = {
348
+ UNAUTHENTICATED: 'UNAUTHENTICATED',
349
+ INVALID_PASSWORD: 'INVALID_PASSWORD',
350
+ TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
351
+ RATE_LIMITED: 'RATE_LIMITED',
352
+ CSRF_REJECTED: 'CSRF_REJECTED',
353
+ /** The action needs the password entered again (POST /reauth), then a retry. */
354
+ REAUTH_REQUIRED: 'REAUTH_REQUIRED',
355
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
356
+ NOT_FOUND: 'NOT_FOUND',
357
+ SERVER_ERROR: 'SERVER_ERROR',
358
+ };
359
+
360
+ /** Which tracking endpoint a request came through. */
361
+ const VIEW_LOG_SOURCE = {
362
+ REGISTER_VIEW: 'registerView',
363
+ EVENT: 'event',
364
+ /** Time on page and scroll depth for a view already recorded. */
365
+ ENGAGE: 'engage',
366
+ };
367
+
368
+ /** Service-owned tables. All carry the reserved `_` prefix. */
369
+ const ADMIN_LOG_TABLE = '_admin_log';
370
+ const ADMIN_SESSIONS_TABLE = '_admin_sessions';
371
+ const VIEW_LOG_TABLE = '_view_log';
372
+ /** Tracking requests that were not stored, counted per minute. */
373
+ const TRACKING_REJECTIONS_TABLE = '_tracking_rejections';
374
+
375
+ /** The tracking pipeline's own bounds. */
376
+ const TRACKING = {
377
+ /** Longest visible time one page view may report. */
378
+ MAX_ENGAGED_MS: 6 * 60 * 60 * 1000,
379
+ /** Engagement is only accepted for a view recorded this recently. */
380
+ ENGAGE_WINDOW_HOURS: 24,
381
+ /** A beacon is a few dozen bytes; this bounds what is even parsed. */
382
+ ENGAGE_BODY_BYTES: 1024,
383
+ /** How often counted rejections are written to the database. */
384
+ REJECTION_FLUSH_MS: 15_000,
385
+ /**
386
+ * Distinct rejection keys kept with their app, detail, and hostname, per
387
+ * minute and per hour, however often the counts are written. Beyond these,
388
+ * a request is still counted, by minute, endpoint, and reason alone, so a
389
+ * flood of made-up values cannot grow memory or the table: at most a few
390
+ * thousand rows an hour, whatever arrives.
391
+ */
392
+ REJECTION_MAX_KEYS_PER_MINUTE: 100,
393
+ REJECTION_MAX_KEYS_PER_HOUR: 1000,
394
+ /** Rejections are only ever stored as per-minute counts. */
395
+ REJECTION_BUCKET_MS: 60 * 1000,
396
+ };
397
+
398
+ /** What happened to one tracking request, as the tracking log reports it. */
399
+ const TRACKING_OUTCOME = {
400
+ /** Stored, and the first view of the page by this visitor in the window. */
401
+ RECORDED: 'recorded',
402
+ /** Stored, but the same visitor viewed it again within the window. */
403
+ REPEAT: 'repeat',
404
+ /** A crawler, preview fetcher, or headless browser; counted, not stored. */
405
+ BOT: 'bot',
406
+ /** Refused; the reason says why. */
407
+ REJECTED: 'rejected',
408
+ };
409
+
410
+ /** Why a tracking request was not stored. */
411
+ const REJECTION_REASON = {
412
+ BOT: 'bot',
413
+ UNKNOWN_APP: 'unknown_app',
414
+ ORIGIN_NOT_ALLOWED: 'origin_not_allowed',
415
+ INVALID_REQUEST: 'invalid_request',
416
+ INVALID_IP: 'invalid_ip',
417
+ RATE_LIMITED: 'rate_limited',
418
+ /** Engagement for a view that does not exist, is trashed, or is too old. */
419
+ UNKNOWN_VIEW: 'unknown_view',
420
+ SERVER_ERROR: 'server_error',
421
+ };
422
+
423
+ /** Canonical UUID text form, any version. Admin row IDs are validated with it. */
424
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
425
+
120
426
  /** Recognised event types. `pageview` is the only one the server itself emits. */
121
427
  const EVENT_TYPE = {
122
428
  PAGEVIEW: 'pageview',
@@ -139,6 +445,8 @@ const SOURCE_TYPE = {
139
445
  EMAIL: 'email',
140
446
  CAMPAIGN: 'campaign',
141
447
  REFERRAL: 'referral',
448
+ /** From another page of the same site: a click within it, not a way in. */
449
+ INTERNAL: 'internal',
142
450
  UNKNOWN: 'unknown',
143
451
  };
144
452
 
@@ -171,7 +479,7 @@ const SCOPE_ALL = '*';
171
479
  * identifiers cannot be bound as parameters. Until now app IDs only ever came
172
480
  * from trusted local config; they can now arrive over HTTP from the admin API,
173
481
  * so the character set is restricted to what is unambiguously safe as an
174
- * identifier. This is the gate — not a nicety.
482
+ * identifier. This is the gate, not a nicety.
175
483
  *
176
484
  * Letters, digits, underscore, and hyphen only. A backtick is the sole
177
485
  * character that can terminate a quoted identifier, and none of these can;
@@ -180,7 +488,7 @@ const SCOPE_ALL = '*';
180
488
  */
181
489
  const APP_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
182
490
 
183
- /** Reserved prefix for the service's own tables (`_migrations`, `_apps`). */
491
+ /** Reserved prefix for the service's own tables (`_migrations`, `_apps`, logs). */
184
492
  const RESERVED_TABLE_PREFIX = '_';
185
493
 
186
494
  /** Internal registry of dynamically provisioned apps. */
@@ -213,6 +521,29 @@ module.exports = {
213
521
  DATABASE,
214
522
  SERVER,
215
523
  PRIVACY,
524
+ ADMIN,
525
+ VIEW_STATUS,
526
+ ADMIN_RANGE,
527
+ ADMIN_RANGE_DAYS,
528
+ TREND_BUCKET,
529
+ TREND_BUCKET_MAX_DAYS,
530
+ ANALYSIS_TOP_N,
531
+ ANALYSIS,
532
+ MODIFIED_FILTER,
533
+ SORT_ORDER,
534
+ ADMIN_SORT_COLUMNS,
535
+ EDITABLE_FIELDS,
536
+ ADMIN_ACTION,
537
+ ADMIN_ERROR_CODE,
538
+ VIEW_LOG_SOURCE,
539
+ ADMIN_LOG_TABLE,
540
+ ADMIN_SESSIONS_TABLE,
541
+ VIEW_LOG_TABLE,
542
+ TRACKING_REJECTIONS_TABLE,
543
+ TRACKING,
544
+ TRACKING_OUTCOME,
545
+ REJECTION_REASON,
546
+ UUID_PATTERN,
216
547
  EVENT_TYPE,
217
548
  TREND_PERIOD,
218
549
  TREND_PERIODS,