@harshankur/viewcounter 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
package/index.js CHANGED
@@ -1,16 +1,17 @@
1
1
  const express = require('express');
2
2
  const cors = require('cors');
3
3
  const helmet = require('helmet');
4
- const rateLimit = require('express-rate-limit');
5
4
 
6
- const { ADMIN, APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
5
+ const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
7
6
  const config = require('./config');
8
7
  const DatabaseManager = require('./db/DatabaseManager');
9
8
  const logger = require('./utils/logger');
10
- const { buildCorsOptions } = require('./middleware/security');
11
- const { createAnalyticsRouter } = require('./routes/analytics');
9
+ const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
10
+ const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
12
11
  const { createAdminRouter } = require('./routes/admin');
13
12
  const { startRetention } = require('./db/retention');
13
+ const { createDbSessionStore } = require('./db/adminSessionStore');
14
+ const { openCityLookup } = require('./utils/geoCity');
14
15
 
15
16
  logger.configure({ level: config.server.logLevel });
16
17
 
@@ -18,6 +19,9 @@ const dbManager = new DatabaseManager(config.dbInfo);
18
19
  let isServerReady = false;
19
20
  let httpServer = null;
20
21
  let stopRetention = () => {};
22
+ let analyticsRouter = null;
23
+ /** The optional city database, opened at startup; both routers read it per request. */
24
+ const geo = { city: null };
21
25
 
22
26
  /**
23
27
  * Build the Express application.
@@ -44,38 +48,47 @@ function createApp() {
44
48
  config,
45
49
  adminRepo: dbManager.admin,
46
50
  logRepo: dbManager.logs,
51
+ // In the database, so a restart or deploy signs nobody out.
52
+ sessionStore: createDbSessionStore(dbManager, {
53
+ idleMs: config.admin.sessionIdleMs,
54
+ absoluteMs: config.admin.sessionMaxAgeMs,
55
+ }),
47
56
  isReady: () => isServerReady,
57
+ geo,
48
58
  }));
49
59
  }
50
60
 
61
+ const router = createAnalyticsRouter({
62
+ config,
63
+ dbManager,
64
+ isReady: () => isServerReady,
65
+ geo,
66
+ });
67
+ analyticsRouter = router;
68
+
69
+ // A site missing from CORS_ORIGINS is refused at the browser's preflight;
70
+ // counted, so the tracking log shows it.
71
+ app.use(countRefusedPreflights(config.server.corsOrigins, {
72
+ isTrackingPath: (path) => Boolean(trackingSourceFor(path)),
73
+ onRefused: (req) => router.countRejection(req, REJECTION_REASON.ORIGIN_NOT_ALLOWED, { detail: 'CORS_ORIGINS' }),
74
+ }));
51
75
  app.use(cors(buildCorsOptions(config.server.corsOrigins)));
52
76
 
53
77
  // Bounded well below body-parser's 100kb default; /event is the only
54
78
  // endpoint taking a body and its payload is small.
55
79
  app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
56
80
 
57
- app.use(rateLimit({
58
- windowMs: config.server.rateLimit.windowMs,
59
- limit: config.server.rateLimit.max,
60
- message: { message: 'Too many requests, please try again later.' },
61
- standardHeaders: true,
62
- legacyHeaders: false,
81
+ // A tracking request turned away here is counted in the tracking log like
82
+ // any other refusal (in memory, written in batches).
83
+ app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
84
+ if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
63
85
  }));
64
86
 
65
- app.use(createAnalyticsRouter({
66
- config,
67
- dbManager,
68
- isReady: () => isServerReady,
69
- }));
87
+ app.use(router);
70
88
 
71
- // Malformed JSON and payloads over the limit surface here.
72
- // eslint-disable-next-line no-unused-vars
73
- app.use((err, req, res, next) => {
74
- const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
75
- logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
76
- res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
77
- .json({ message: 'Malformed or oversized request' });
78
- });
89
+ // Malformed JSON and payloads over the limit surface here, and are
90
+ // counted in the tracking log when they were sent to a tracking endpoint.
91
+ app.use(router.bodyErrorHandler);
79
92
 
80
93
  return app;
81
94
  }
@@ -88,7 +101,7 @@ const app = createApp();
88
101
  * Config-declared apps and registry-declared apps are unioned: the file stays
89
102
  * authoritative for a fixed single-operator deployment, while the registry
90
103
  * carries tenants provisioned at runtime. A registry that cannot be read is a
91
- * warning rather than a startup failure — config-declared apps still work.
104
+ * warning rather than a startup failure: config-declared apps still work.
92
105
  */
93
106
  async function mergeRegisteredApps() {
94
107
  try {
@@ -117,6 +130,14 @@ async function mergeRegisteredApps() {
117
130
  const initializeServer = async () => {
118
131
  try {
119
132
  config.validate();
133
+
134
+ // A configured city database that cannot be opened stops startup: the
135
+ // operator asked for it, and running without it would hide that.
136
+ if (config.geo?.cityDatabase) {
137
+ geo.city = await openCityLookup(config.geo.cityDatabase);
138
+ logger.info(`City database loaded (${geo.city.databaseType})`);
139
+ }
140
+
120
141
  await dbManager.initialize(config.allowed.appId);
121
142
 
122
143
  // Merge dynamically registered tenants into the live allowlist, so
@@ -153,8 +174,8 @@ const initializeServer = async () => {
153
174
  }
154
175
  };
155
176
 
156
- // Only when run directly. Requiring this module as a library — to mount
157
- // createAnalyticsRouter into an existing app — must not validate config,
177
+ // Only when run directly. Requiring this module as a library (to mount
178
+ // createAnalyticsRouter into an existing app) must not validate config,
158
179
  // connect to a database, or bind a port as a side effect of the import.
159
180
  if (require.main === module) {
160
181
  initializeServer();
@@ -178,6 +199,7 @@ const shutdown = async (signal, exitCode = 0) => {
178
199
 
179
200
  try {
180
201
  stopRetention();
202
+ await analyticsRouter?.flushRejections();
181
203
  if (httpServer) {
182
204
  await new Promise((resolve) => httpServer.close(resolve));
183
205
  }
@@ -212,6 +234,7 @@ module.exports.createApp = createApp;
212
234
  module.exports.createAnalyticsRouter = createAnalyticsRouter;
213
235
  module.exports.createAdminRouter = createAdminRouter;
214
236
  module.exports.startRetention = startRetention;
237
+ module.exports.createDbSessionStore = createDbSessionStore;
215
238
  module.exports.DatabaseManager = DatabaseManager;
216
239
  module.exports.dbManager = dbManager;
217
240
  module.exports.initializeServer = initializeServer;
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Admin UI authentication: password login, server-side sessions, and CSRF.
2
+ * Admin UI authentication: password login, server-side sessions, CSRF, and a
3
+ * fresh password for the actions that cannot be undone.
3
4
  *
4
- * Sessions live in memory. A restart signs every admin out, which is the safe
5
- * direction to fail in and costs one login. The browser holds only an opaque
6
- * random token in an HttpOnly cookie; the store is keyed by that token's
7
- * SHA-256, so a memory dump of the store does not yield usable cookies.
5
+ * The browser holds only an opaque random token in an HttpOnly cookie. Stores
6
+ * are keyed by that token's SHA-256, so reading a store (the in-memory one
7
+ * here, or the database table the server uses so a restart signs nobody out)
8
+ * yields no usable session.
8
9
  *
9
10
  * CSRF is defeated three ways, each sufficient on its own in a modern browser:
10
11
  * the cookie is SameSite=Strict, every mutating request must echo a per-session
11
12
  * token in a header that a cross-site form cannot set, and a present Origin
12
- * header must match this server.
13
+ * header must match this server. The CSRF token is an HMAC of the session
14
+ * token, so it is never stored and cannot be computed without the cookie.
13
15
  */
14
16
 
15
17
  const crypto = require('crypto');
@@ -27,7 +29,32 @@ function hashToken(token) {
27
29
  return crypto.createHash('sha256').update(token).digest('hex');
28
30
  }
29
31
 
32
+ /** @returns {string} a new session token, 256 random bits */
33
+ function newToken() {
34
+ return crypto.randomBytes(ADMIN.SESSION_TOKEN_BYTES).toString('base64url');
35
+ }
36
+
37
+ /**
38
+ * The CSRF token for a session: an HMAC of its token, so it is the same for
39
+ * the life of the session, is never stored, and needs the cookie to compute.
40
+ * @param {string} token
41
+ * @returns {string}
42
+ */
43
+ function sessionCsrfToken(token) {
44
+ return crypto.createHmac('sha256', token).update('viewcounter-admin-csrf').digest('base64url');
45
+ }
46
+
30
47
  /**
48
+ * The in-memory session store: used by tests and by an embedding app that
49
+ * passes no store of its own. A restart signs everyone out.
50
+ *
51
+ * Every store has the same async interface:
52
+ * create() -> { token, session }
53
+ * get(token) -> session or null; extends the idle window
54
+ * destroy(token) -> whether a session was ended
55
+ * confirmPassword(token) -> records that the password was just entered
56
+ * where a session is { id, passwordAgeMs }.
57
+ *
31
58
  * @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
32
59
  * now?: () => number }} [options]
33
60
  */
@@ -37,27 +64,19 @@ function createSessionStore({
37
64
  maxSessions = ADMIN.MAX_SESSIONS,
38
65
  now = Date.now,
39
66
  } = {}) {
40
- /** @type {Map<string, {id: string, csrfToken: string, createdAt: number, lastSeenAt: number}>} */
67
+ /** @type {Map<string, {id: string, createdAt: number, lastSeenAt: number, passwordAt: number}>} */
41
68
  const sessions = new Map();
42
69
 
43
- function isExpired(session, at) {
44
- return at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs;
45
- }
70
+ const view = (session, at) => ({ id: session.id, passwordAgeMs: at - session.passwordAt });
46
71
 
47
72
  return {
48
- /**
49
- * Start a session.
50
- * @returns {{ token: string, session: {id: string, csrfToken: string, createdAt: number, lastSeenAt: number} }}
51
- */
52
- create() {
73
+ idleMs,
74
+ absoluteMs,
75
+
76
+ async create() {
53
77
  const at = now();
54
- const token = crypto.randomBytes(ADMIN.SESSION_TOKEN_BYTES).toString('base64url');
55
- const session = {
56
- id: crypto.randomUUID(),
57
- csrfToken: crypto.randomBytes(ADMIN.CSRF_TOKEN_BYTES).toString('base64url'),
58
- createdAt: at,
59
- lastSeenAt: at,
60
- };
78
+ const token = newToken();
79
+ const session = { id: crypto.randomUUID(), createdAt: at, lastSeenAt: at, passwordAt: at };
61
80
 
62
81
  // Bounded: the oldest session goes first. Map preserves insertion
63
82
  // order, so the first key is always the oldest.
@@ -65,34 +84,34 @@ function createSessionStore({
65
84
  sessions.delete(sessions.keys().next().value);
66
85
  }
67
86
  sessions.set(hashToken(token), session);
68
- return { token, session };
87
+ return { token, session: view(session, at) };
69
88
  },
70
89
 
71
- /**
72
- * Look up a live session and extend its idle window.
73
- * @param {string|undefined} token
74
- */
75
- get(token) {
90
+ async get(token) {
76
91
  if (typeof token !== 'string' || token.length === 0) return null;
77
92
  const key = hashToken(token);
78
93
  const session = sessions.get(key);
79
94
  if (!session) return null;
80
95
 
81
96
  const at = now();
82
- if (isExpired(session, at)) {
97
+ if (at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs) {
83
98
  sessions.delete(key);
84
99
  return null;
85
100
  }
86
101
  session.lastSeenAt = at;
87
- return session;
102
+ return view(session, at);
88
103
  },
89
104
 
90
- /** @param {string|undefined} token */
91
- destroy(token) {
105
+ async destroy(token) {
92
106
  if (typeof token !== 'string' || token.length === 0) return false;
93
107
  return sessions.delete(hashToken(token));
94
108
  },
95
109
 
110
+ async confirmPassword(token) {
111
+ const session = typeof token === 'string' ? sessions.get(hashToken(token)) : null;
112
+ if (session) session.passwordAt = now();
113
+ },
114
+
96
115
  get size() {
97
116
  return sessions.size;
98
117
  },
@@ -111,27 +130,44 @@ function readToken(req) {
111
130
  * (`req.adminBasePath`), so the cookie never travels to the public analytics
112
131
  * endpoints and still works when another app mounts the router elsewhere.
113
132
  */
114
- function cookieOptions(req) {
133
+ function cookieOptions(req, maxAge = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS) {
115
134
  return {
116
135
  httpOnly: true,
117
136
  sameSite: 'strict',
118
137
  secure: req.secure,
119
138
  path: req.adminBasePath || ADMIN.PATH_PREFIX,
120
- maxAge: ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
139
+ maxAge,
121
140
  };
122
141
  }
123
142
 
124
143
  /** @returns {import('express').RequestHandler} */
125
144
  function requireAdminSession(store) {
126
- return (req, res, next) => {
127
- const token = readToken(req);
128
- const session = store.get(token);
129
- if (!session) {
130
- return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.UNAUTHENTICATED });
145
+ return async (req, res, next) => {
146
+ try {
147
+ const token = readToken(req);
148
+ const session = await store.get(token);
149
+ if (!session) {
150
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.UNAUTHENTICATED });
151
+ }
152
+ req.adminSession = session;
153
+ req.adminToken = token;
154
+ return next();
155
+ } catch (error) {
156
+ return next(error);
131
157
  }
132
- req.adminSession = session;
133
- req.adminToken = token;
134
- return next();
158
+ };
159
+ }
160
+
161
+ /**
162
+ * For actions that cannot be undone: the password must have been entered in
163
+ * the last `windowMs`, at sign-in or through POST /reauth. Otherwise the UI is
164
+ * told to ask for it and retry.
165
+ * @returns {import('express').RequestHandler}
166
+ */
167
+ function requireRecentPassword(windowMs = ADMIN.REAUTH_WINDOW_MS) {
168
+ return (req, res, next) => {
169
+ if (req.adminSession && req.adminSession.passwordAgeMs <= windowMs) return next();
170
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.REAUTH_REQUIRED });
135
171
  };
136
172
  }
137
173
 
@@ -172,7 +208,8 @@ function requireCsrf() {
172
208
  const session = req.adminSession;
173
209
 
174
210
  const originOk = originAllowed(req);
175
- const tokenOk = Boolean(session) && safeEqual(presented, session.csrfToken);
211
+ const tokenOk = Boolean(session) && typeof req.adminToken === 'string'
212
+ && safeEqual(presented, sessionCsrfToken(req.adminToken));
176
213
 
177
214
  if (!originOk || !tokenOk) {
178
215
  return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
@@ -193,7 +230,10 @@ function verifyPassword(presented, configured) {
193
230
  module.exports = {
194
231
  createSessionStore,
195
232
  requireAdminSession,
233
+ requireRecentPassword,
196
234
  requireCsrf,
235
+ sessionCsrfToken,
236
+ newToken,
197
237
  verifyPassword,
198
238
  cookieOptions,
199
239
  expectedOrigin,
@@ -23,9 +23,11 @@ const {
23
23
  SORT_ORDER,
24
24
  UUID_PATTERN,
25
25
  VIEW_LOG_SOURCE,
26
+ TRACKING_OUTCOME,
26
27
  VIEW_STATUS,
27
28
  } = require('../constants');
28
29
  const ReferrerParser = require('../utils/referrerParser');
30
+ const { FILTER_COLUMNS } = require('../db/analysis');
29
31
  const { jsonByteLength } = require('../utils/stringUtils');
30
32
 
31
33
  const within = (values) => (value) => Object.values(values).includes(value);
@@ -75,6 +77,48 @@ const validateLogin = () => [
75
77
  .withMessage('password is required'),
76
78
  ];
77
79
 
80
+ /** The longest breakdown value a `where` filter can hold: a referrer or page path. */
81
+ const WHERE_VALUE_MAX_LENGTH = Math.max(FIELD_MAX_LENGTH.REFERRER, FIELD_MAX_LENGTH.PAGE_PATH);
82
+ /** Every dimension at its longest, as JSON, with room for escaping. */
83
+ const WHERE_MAX_LENGTH = Object.keys(FILTER_COLUMNS).length * (WHERE_VALUE_MAX_LENGTH + 32) * 2;
84
+
85
+ /**
86
+ * Read `where`: breakdown filters as a JSON object of dimension name to
87
+ * value, with null for rows that have none, such as {"country":"DE"}.
88
+ * @param {unknown} raw
89
+ * @returns {{ ok: true, filters: Record<string, string|null> } | { ok: false, error: string }}
90
+ */
91
+ function readWhere(raw) {
92
+ if (raw === undefined || raw === '') return { ok: true, filters: {} };
93
+ if (typeof raw !== 'string' || raw.length > WHERE_MAX_LENGTH) return { ok: false, error: 'where must be a short JSON object' };
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ } catch {
98
+ return { ok: false, error: 'where must be a JSON object' };
99
+ }
100
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'where must be a JSON object' };
101
+ const filters = {};
102
+ for (const [dim, value] of Object.entries(parsed)) {
103
+ if (!Object.hasOwn(FILTER_COLUMNS, dim)) return { ok: false, error: 'where names a dimension that cannot be filtered' };
104
+ if (value !== null && (typeof value !== 'string' || value.length > WHERE_VALUE_MAX_LENGTH)) {
105
+ return { ok: false, error: `each where value must be null or a string of at most ${WHERE_VALUE_MAX_LENGTH} characters` };
106
+ }
107
+ filters[dim] = value;
108
+ }
109
+ return { ok: true, filters };
110
+ }
111
+
112
+ /**
113
+ * The breakdown filters of a request that passed validation.
114
+ * @param {unknown} raw
115
+ * @returns {Record<string, string|null>}
116
+ */
117
+ function parseWhere(raw) {
118
+ const result = readWhere(raw);
119
+ return result.ok ? result.filters : {};
120
+ }
121
+
78
122
  /** Filters shared by a listing and its analysis, for one app or all. */
79
123
  const filterQueries = () => [
80
124
  query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
@@ -90,6 +134,10 @@ const filterQueries = () => [
90
134
  .isString().withMessage('search must be a string')
91
135
  .isLength({ max: ADMIN.SEARCH_MAX_LENGTH })
92
136
  .withMessage(`search must be at most ${ADMIN.SEARCH_MAX_LENGTH} characters`),
137
+ query('where')
138
+ .optional()
139
+ .custom((value) => readWhere(value).ok)
140
+ .withMessage((value) => readWhere(value).error),
93
141
  ];
94
142
 
95
143
  /** A listing of one app (with `allowed`) or of every app (without). */
@@ -108,6 +156,15 @@ const validateAnalysis = (allowed) => [
108
156
  ...filterQueries(),
109
157
  ];
110
158
 
159
+ /** The event types of one app (with `allowed`) or of every app, in a status. */
160
+ const validateEventTypes = (allowed) => [
161
+ ...(allowed ? [adminAppIdParam(allowed)] : []),
162
+ query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
163
+ ];
164
+
165
+ /** Right now takes no filters, only the app for the per-app route. */
166
+ const validateRealtime = (allowed) => (allowed ? [adminAppIdParam(allowed)] : []);
167
+
111
168
 
112
169
  /**
113
170
  * Validate one field of an edit and return the column value to store.
@@ -157,7 +214,9 @@ function checkField(field, value, deviceSizes) {
157
214
  *
158
215
  * Only EDITABLE_FIELDS may appear. A change to `referrer` also re-derives
159
216
  * `referrer_domain` and `source_type` with the same parser the write path
160
- * uses, so the three can never disagree.
217
+ * uses, and stores the referrer the same way (origin and path only). Whether
218
+ * it is on the row's own site, and so internal, depends on each row's site,
219
+ * which the repository applies per row (AdminRepository.updateContent).
161
220
  *
162
221
  * @param {unknown} changes
163
222
  * @param {string[]} deviceSizes
@@ -221,13 +280,16 @@ const validateAdminLogListing = (allowed) => [
221
280
  query('action').optional().custom(within(ADMIN_ACTION)).withMessage('Invalid action'),
222
281
  ];
223
282
 
224
- const validateViewLogListing = (allowed) => [
283
+ const validateTrackingLogListing = (allowed) => [
225
284
  pageQuery(),
226
285
  pageSizeQuery(),
227
286
  adminAppIdFilter(allowed),
228
287
  query('source').optional().custom(within(VIEW_LOG_SOURCE)).withMessage('Invalid source'),
288
+ query('outcome').optional().custom(within(TRACKING_OUTCOME)).withMessage('Invalid outcome'),
229
289
  ];
230
290
 
291
+ const validateTrackingSummary = (allowed) => [adminAppIdFilter(allowed)];
292
+
231
293
  /** Admin-shaped validation failure: a stable code plus per-field detail. */
232
294
  function handleAdminValidation(req, res, next) {
233
295
  const errors = validationResult(req);
@@ -239,14 +301,18 @@ function handleAdminValidation(req, res, next) {
239
301
  }
240
302
 
241
303
  module.exports = {
304
+ parseWhere,
242
305
  validateLogin,
243
306
  validateViewListing,
244
307
  validateAnalysis,
308
+ validateEventTypes,
309
+ validateRealtime,
245
310
  validateEdit,
246
311
  validateNote,
247
312
  validateBatch,
248
313
  validateAdminLogListing,
249
- validateViewLogListing,
314
+ validateTrackingLogListing,
315
+ validateTrackingSummary,
250
316
  handleAdminValidation,
251
317
  resolveChanges,
252
318
  checkField,
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Two distinct steps, deliberately separate:
5
5
  *
6
- * requireReadApiKey — *authentication*: is this a key we issued?
7
- * requireAppScope — *authorization*: may THIS key read THIS app?
6
+ * requireReadApiKey *authentication*: is this a key we issued?
7
+ * requireAppScope *authorization*: may THIS key read THIS app?
8
8
  *
9
9
  * The second step is what makes the service multi-tenant. Without it a valid
10
10
  * key read every tenant's analytics, because the appId allowlist only ever
@@ -10,7 +10,7 @@ const { HTTP_STATUS } = require('../constants');
10
10
  * agent-instructions SECURITY.md §9: `cors()` with no options is never the
11
11
  * default. The wildcard mattered more here than the usual "no credentials, so
12
12
  * it's harmless" reasoning suggests, because the read endpoints served real
13
- * data — `*` made them script-readable from any origin, not merely reachable.
13
+ * data: `*` made them script-readable from any origin, not merely reachable.
14
14
  *
15
15
  * @param {string[]} allowedOrigins
16
16
  * @returns {import('cors').CorsOptions}
@@ -32,6 +32,26 @@ function buildCorsOptions(allowedOrigins) {
32
32
  };
33
33
  }
34
34
 
35
+ /**
36
+ * Before a browser sends JSON to another site, it asks (a CORS preflight). A
37
+ * site missing from CORS_ORIGINS is refused there, and the request itself
38
+ * never arrives, so nothing else could count it. This counts the refusal, for
39
+ * the tracking-log paths `isTrackingPath` names, so a misconfigured
40
+ * CORS_ORIGINS shows up in the tracking log. Place it before `cors()`.
41
+ *
42
+ * @param {string[]} allowedOrigins as given to buildCorsOptions
43
+ * @param {{ isTrackingPath: (path: string) => boolean, onRefused: (req: import('express').Request) => void }} hooks
44
+ * @returns {import('express').RequestHandler}
45
+ */
46
+ function countRefusedPreflights(allowedOrigins, { isTrackingPath, onRefused }) {
47
+ const allowlist = new Set(allowedOrigins);
48
+ return (req, res, next) => {
49
+ const origin = req.get('origin');
50
+ if (req.method === 'OPTIONS' && origin && !allowlist.has(origin) && isTrackingPath(req.path)) onRefused(req);
51
+ next();
52
+ };
53
+ }
54
+
35
55
  /**
36
56
  * Extract the requesting origin, falling back to the referrer's origin.
37
57
  * @returns {string|null}
@@ -64,9 +84,11 @@ function requestOrigin(req) {
64
84
  * every appId still in that state.
65
85
  *
66
86
  * @param {{ origins: Record<string, string[]> }} allowed
87
+ * @param {{ onReject?: (req: import('express').Request, appId: string) => void }} [options]
88
+ * told about each refusal, so the tracking log can count it
67
89
  * @returns {import('express').RequestHandler}
68
90
  */
69
- function requireRegisteredOrigin(allowed) {
91
+ function requireRegisteredOrigin(allowed, { onReject = () => {} } = {}) {
70
92
  const origins = allowed?.origins || {};
71
93
 
72
94
  return (req, res, next) => {
@@ -82,6 +104,7 @@ function requireRegisteredOrigin(allowed) {
82
104
  return next();
83
105
  }
84
106
 
107
+ onReject(req, appId);
85
108
  return res.status(HTTP_STATUS.FORBIDDEN).json({
86
109
  message: 'Request origin is not registered for this appId',
87
110
  });
@@ -103,6 +126,7 @@ function noStore(req, res, next) {
103
126
 
104
127
  module.exports = {
105
128
  buildCorsOptions,
129
+ countRefusedPreflights,
106
130
  requireRegisteredOrigin,
107
131
  requestOrigin,
108
132
  noStore,
@@ -5,8 +5,12 @@ const {
5
5
  HTTP_STATUS,
6
6
  PAYLOAD_LIMITS,
7
7
  QUERY_LIMITS,
8
+ REJECTION_REASON,
9
+ TRACKING,
8
10
  TREND_PERIODS,
11
+ UUID_PATTERN,
9
12
  } = require('../constants');
13
+ const { UTM_PARAMETERS } = require('../utils/visitorContext');
10
14
  const { jsonByteLength } = require('../utils/stringUtils');
11
15
  const { isValidAppId } = require('../utils/appIdUtils');
12
16
 
@@ -60,7 +64,7 @@ const boundedBody = (name, max) =>
60
64
  *
61
65
  * Deliberately no `.toInt()` sanitizer: under Express 5 `req.query` is a
62
66
  * getter-only property, so a sanitizer appears to work but never writes the
63
- * coerced value back — the handler would still receive a string and bind it
67
+ * coerced value back: the handler would still receive a string and bind it
64
68
  * into `LIMIT ?`, which MySQL rejects. Handlers coerce explicitly instead,
65
69
  * after this validator has established the value is a valid integer in range.
66
70
  */
@@ -83,6 +87,24 @@ const validateRegisterView = (allowedValues) => [
83
87
  boundedQuery('title', FIELD_MAX_LENGTH.PAGE_TITLE),
84
88
  boundedQuery('referrer', FIELD_MAX_LENGTH.REFERRER),
85
89
  boundedQuery('sessionId', FIELD_MAX_LENGTH.SESSION_ID),
90
+ ...UTM_PARAMETERS.map((name) => boundedQuery(name, FIELD_MAX_LENGTH.UTM)),
91
+ ];
92
+
93
+ /**
94
+ * Validate an engagement beacon: how long a recorded view's page was visible
95
+ * and how far it was scrolled.
96
+ */
97
+ const validateEngage = (allowedValues) => [
98
+ body('appId')
99
+ .notEmpty().withMessage('appId is required')
100
+ .custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId'),
101
+ body('id')
102
+ .isString().withMessage('id must be a string')
103
+ .matches(UUID_PATTERN).withMessage('id must be a view ID'),
104
+ body('ms')
105
+ .isInt({ min: 0, max: TRACKING.MAX_ENGAGED_MS }).withMessage(`ms must be an integer between 0 and ${TRACKING.MAX_ENGAGED_MS}`),
106
+ body('scroll')
107
+ .isInt({ min: 0, max: 100 }).withMessage('scroll must be an integer between 0 and 100'),
86
108
  ];
87
109
 
88
110
  /**
@@ -165,7 +187,7 @@ const validateSessionRequest = (allowedValues) => [
165
187
  * Validate an app-provisioning request.
166
188
  *
167
189
  * `appId` here becomes a table identifier, so it is checked against the strict
168
- * pattern rather than an allowlist — there is no allowlist yet, that is the
190
+ * pattern rather than an allowlist: there is no allowlist yet, that is the
169
191
  * point of the call.
170
192
  */
171
193
  const validateAppRegistration = () => [
@@ -199,14 +221,40 @@ const handleValidationErrors = (req, res, next) => {
199
221
  return next();
200
222
  };
201
223
 
224
+ /**
225
+ * The same 422 as handleValidationErrors, for a tracking endpoint: the refusal
226
+ * is also reported, so the tracking log can say why a site's views are not
227
+ * arriving. An appId that was given but is not allowed is its own reason,
228
+ * since a misspelled one is the commonest cause; anything else, including a
229
+ * missing appId, names the first field that failed.
230
+ *
231
+ * @param {(req: import('express').Request, reason: string, details: { appId?: string, detail?: string }) => void} onReject
232
+ * @returns {import('express').RequestHandler}
233
+ */
234
+ const handleTrackingValidation = (onReject) => (req, res, next) => {
235
+ const errors = validationResult(req);
236
+ if (errors.isEmpty()) return next();
237
+
238
+ const failed = errors.array();
239
+ const appIdError = failed.find((error) => error.path === 'appId');
240
+ if (appIdError && typeof appIdError.value === 'string' && appIdError.value !== '') {
241
+ onReject(req, REJECTION_REASON.UNKNOWN_APP, { appId: appIdError.value });
242
+ } else {
243
+ onReject(req, REJECTION_REASON.INVALID_REQUEST, { detail: (appIdError || failed[0]).path });
244
+ }
245
+ return handleValidationErrors(req, res, next);
246
+ };
247
+
202
248
  module.exports = {
203
249
  validateAppRegistration,
204
250
  validateRegisterView,
205
251
  validateEvent,
252
+ validateEngage,
206
253
  validateStatsRequest,
207
254
  validateTrendsRequest,
208
255
  validateListRequest,
209
256
  validateViewsRequest,
210
257
  validateSessionRequest,
211
258
  handleValidationErrors,
259
+ handleTrackingValidation,
212
260
  };