@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,117 @@
1
+ /**
2
+ * Counts tracking requests that were not stored (bots and rejections) and
3
+ * writes the counts to the tracking log in batches.
4
+ *
5
+ * A rejected request must cost less than an accepted one, or the tracking log
6
+ * would turn every flood the rate limiter turns away into database writes. So
7
+ * nothing is written per request: counts accumulate in memory, keyed by
8
+ * minute, endpoint, reason, app, detail, and hostname, and one upsert every
9
+ * TRACKING.REJECTION_FLUSH_MS adds them to what is stored.
10
+ *
11
+ * The key space is bounded as well, and so are the rows written. App IDs,
12
+ * details, and hostnames can be made up by whoever sends the request, so only
13
+ * TRACKING.REJECTION_MAX_KEYS_PER_MINUTE distinct keys a minute, and
14
+ * TRACKING.REJECTION_MAX_KEYS_PER_HOUR an hour, keep them. The budgets hold
15
+ * across flushes: writing the counts does not start a new allowance. Beyond
16
+ * them a key keeps only its minute, endpoint, and reason. The count stays
17
+ * exact; the made-up values are dropped.
18
+ */
19
+
20
+ const { TRACKING } = require('../constants');
21
+
22
+ /** The shape an app ID must have to be kept; anything else is counted as blank. */
23
+ const APP_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
24
+ /** Details are our own short labels (a field name, a bot's name), but are bounded anyway. */
25
+ const DETAIL_MAX = 64;
26
+
27
+ const cleanAppId = (value) => (typeof value === 'string' && APP_ID_SHAPE.test(value) ? value : '');
28
+ const cleanDetail = (value) => (typeof value === 'string' ? value.replace(/[^\x20-\x7e]/g, '').slice(0, DETAIL_MAX) : '');
29
+ const cleanHostname = (value) => (typeof value === 'string' ? value : '');
30
+
31
+ const HOUR_MS = 60 * 60 * 1000;
32
+
33
+ /**
34
+ * @param {{ write: (rows: object[]) => Promise<unknown>, now?: () => number,
35
+ * flushMs?: number, maxKeysPerMinute?: number, maxKeysPerHour?: number }} options
36
+ */
37
+ function createRejectionCounter({
38
+ write,
39
+ now = Date.now,
40
+ flushMs = TRACKING.REJECTION_FLUSH_MS,
41
+ maxKeysPerMinute = TRACKING.REJECTION_MAX_KEYS_PER_MINUTE,
42
+ maxKeysPerHour = TRACKING.REJECTION_MAX_KEYS_PER_HOUR,
43
+ }) {
44
+ const pending = new Map();
45
+ /** Keys already kept whole, by minute: this minute's and the one before. */
46
+ const keptByMinute = new Map();
47
+ /** How many keys were kept whole, by hour: this hour's. */
48
+ const keptByHour = new Map();
49
+ let timer = null;
50
+
51
+ /** Whether a key may keep its app, detail, and hostname, spending the budgets if new. */
52
+ function keepWhole(minute, key) {
53
+ for (const old of keptByMinute.keys()) if (old < minute - TRACKING.REJECTION_BUCKET_MS) keptByMinute.delete(old);
54
+ const hour = Math.floor(minute / HOUR_MS) * HOUR_MS;
55
+ for (const old of keptByHour.keys()) if (old < hour) keptByHour.delete(old);
56
+
57
+ const kept = keptByMinute.get(minute) || new Set();
58
+ keptByMinute.set(minute, kept);
59
+ if (kept.has(key)) return true;
60
+ const hourCount = keptByHour.get(hour) || 0;
61
+ if (kept.size >= maxKeysPerMinute || hourCount >= maxKeysPerHour) return false;
62
+ kept.add(key);
63
+ keptByHour.set(hour, hourCount + 1);
64
+ return true;
65
+ }
66
+
67
+ function schedule() {
68
+ if (timer) return;
69
+ timer = setTimeout(() => {
70
+ timer = null;
71
+ flush();
72
+ }, flushMs);
73
+ if (typeof timer.unref === 'function') timer.unref();
74
+ }
75
+
76
+ /**
77
+ * Count one request.
78
+ * @param {{ source: string, reason: string, appId?: string, detail?: string, hostname?: string }} rejection
79
+ */
80
+ function count({ source, reason, appId, detail, hostname }) {
81
+ const minute = Math.floor(now() / TRACKING.REJECTION_BUCKET_MS) * TRACKING.REJECTION_BUCKET_MS;
82
+ let entry = { source, reason, appId: cleanAppId(appId), detail: cleanDetail(detail), hostname: cleanHostname(hostname) };
83
+ let key = [minute, entry.source, entry.reason, entry.appId, entry.detail, entry.hostname].join('\u0000');
84
+ const plain = !entry.appId && !entry.detail && !entry.hostname;
85
+ if (!plain && !keepWhole(minute, key)) {
86
+ entry = { source, reason, appId: '', detail: '', hostname: '' };
87
+ key = [minute, source, reason, '', '', ''].join('\u0000');
88
+ }
89
+
90
+ const row = pending.get(key) || { minute: new Date(minute), ...entry, requests: 0 };
91
+ row.requests += 1;
92
+ pending.set(key, row);
93
+ schedule();
94
+ }
95
+
96
+ /** Write everything pending now; also used on shutdown. */
97
+ async function flush() {
98
+ if (timer) {
99
+ clearTimeout(timer);
100
+ timer = null;
101
+ }
102
+ if (pending.size === 0) return;
103
+ const rows = [...pending.values()];
104
+ pending.clear();
105
+ await write(rows);
106
+ }
107
+
108
+ return {
109
+ count,
110
+ flush,
111
+ get pendingKeys() {
112
+ return pending.size;
113
+ },
114
+ };
115
+ }
116
+
117
+ module.exports = { createRejectionCounter };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Scheduled retention: expired trash, and old view-log entries.
3
+ *
4
+ * Trash (GDPR Art. 5(1)(e), storage limitation): a soft-deleted view is kept
5
+ * for TRASH_RETENTION_DAYS so a mistaken delete can be undone, then erased for
6
+ * good.
7
+ *
8
+ * View log: the register of accepted views gains a row per view. It holds no
9
+ * personal data, but unbounded it grows as large as every app table together,
10
+ * so entries older than VIEW_LOG_RETENTION_DAYS are removed.
11
+ *
12
+ * Every run that removes anything leaves an entry in the admin operation log,
13
+ * attributed to the system rather than a session, so nothing is removed
14
+ * silently. The admin log itself is never pruned.
15
+ */
16
+
17
+ const { ADMIN, ADMIN_ACTION } = require('../constants');
18
+ const { logWarning, WarningType } = require('../utils/errorUtils');
19
+
20
+ /**
21
+ * Erase expired trash in every app once.
22
+ *
23
+ * One app failing does not stop the others; the failure is reported and the
24
+ * next run tries again.
25
+ *
26
+ * @param {{ adminRepo: object, logRepo: object, appIds: string[], days: number }} deps
27
+ * @returns {Promise<Record<string, number>>} rows erased per app
28
+ */
29
+ async function purgeExpiredTrash({ adminRepo, logRepo, appIds, days }) {
30
+ const erased = {};
31
+ if (!(days > 0)) return erased;
32
+
33
+ for (const appId of appIds) {
34
+ try {
35
+ const count = await adminRepo.purgeExpired(appId, days);
36
+ erased[appId] = count;
37
+ if (count > 0) {
38
+ await logRepo.writeAdminLog({
39
+ action: ADMIN_ACTION.TRASH_AUTO_PURGED,
40
+ appId,
41
+ targetCount: count,
42
+ });
43
+ }
44
+ } catch (cause) {
45
+ logWarning(WarningType.TRASH_PURGE_FAILED, { appId, cause: cause.message });
46
+ }
47
+ }
48
+ return erased;
49
+ }
50
+
51
+ /**
52
+ * Remove view-log entries older than `days` once. A failure is reported and
53
+ * the next run tries again.
54
+ *
55
+ * @param {{ logRepo: object, days: number }} deps
56
+ * @returns {Promise<number>} entries removed
57
+ */
58
+ async function pruneViewLog({ logRepo, days }) {
59
+ if (!(days > 0)) return 0;
60
+ try {
61
+ const count = await logRepo.pruneViewLog(days);
62
+ if (count > 0) {
63
+ await logRepo.writeAdminLog({ action: ADMIN_ACTION.VIEW_LOG_PRUNED, targetCount: count });
64
+ }
65
+ return count;
66
+ } catch (cause) {
67
+ logWarning(WarningType.VIEW_LOG_PRUNE_FAILED, { cause: cause.message });
68
+ return 0;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run both jobs now and then on an interval. A job whose retention is zero
74
+ * (keep forever) is skipped; when both are, nothing is scheduled.
75
+ *
76
+ * `getAppIds` is called on every run, so apps provisioned at runtime are
77
+ * covered without a restart. The timer is unref'd so it never keeps the
78
+ * process alive on its own.
79
+ *
80
+ * @param {{ adminRepo: object, logRepo: object, getAppIds: () => string[],
81
+ * trashDays: number, viewLogDays: number, intervalMs?: number }} deps
82
+ * @returns {() => void} stops the schedule
83
+ */
84
+ function startRetention({ adminRepo, logRepo, getAppIds, trashDays, viewLogDays, intervalMs = ADMIN.RETENTION_INTERVAL_MS }) {
85
+ if (!(trashDays > 0) && !(viewLogDays > 0)) return () => {};
86
+
87
+ const run = async () => {
88
+ await purgeExpiredTrash({ adminRepo, logRepo, appIds: getAppIds(), days: trashDays });
89
+ await pruneViewLog({ logRepo, days: viewLogDays });
90
+ };
91
+ run();
92
+ const timer = setInterval(run, intervalMs);
93
+ if (typeof timer.unref === 'function') timer.unref();
94
+ return () => clearInterval(timer);
95
+ }
96
+
97
+ module.exports = { purgeExpiredTrash, pruneViewLog, startRetention };
package/index.js CHANGED
@@ -3,18 +3,26 @@ const cors = require('cors');
3
3
  const helmet = require('helmet');
4
4
  const rateLimit = require('express-rate-limit');
5
5
 
6
- const { APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
6
+ const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
7
7
  const config = require('./config');
8
8
  const DatabaseManager = require('./db/DatabaseManager');
9
9
  const logger = require('./utils/logger');
10
- const { buildCorsOptions } = require('./middleware/security');
11
- const { createAnalyticsRouter } = require('./routes/analytics');
10
+ const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
11
+ const { createAnalyticsRouter, trackingSourceFor } = require('./routes/analytics');
12
+ const { createAdminRouter } = require('./routes/admin');
13
+ const { startRetention } = require('./db/retention');
14
+ const { createDbSessionStore } = require('./db/adminSessionStore');
15
+ const { openCityLookup } = require('./utils/geoCity');
12
16
 
13
17
  logger.configure({ level: config.server.logLevel });
14
18
 
15
19
  const dbManager = new DatabaseManager(config.dbInfo);
16
20
  let isServerReady = false;
17
21
  let httpServer = null;
22
+ let stopRetention = () => {};
23
+ let analyticsRouter = null;
24
+ /** The optional city database, opened at startup; both routers read it per request. */
25
+ const geo = { city: null };
18
26
 
19
27
  /**
20
28
  * Build the Express application.
@@ -27,39 +35,71 @@ function createApp() {
27
35
 
28
36
  app.disable('x-powered-by');
29
37
  app.use(helmet());
38
+
39
+ // Trusting the proxy is needed before the admin router sees a request: it
40
+ // decides whether the session cookie is marked Secure from req.secure.
41
+ app.set('trust proxy', config.server.trustProxy);
42
+
43
+ // The admin surface exists only when a password is configured, and is
44
+ // mounted ahead of CORS and the public rate limit: it is same-origin only,
45
+ // must never carry the analytics sites' CORS headers, and has its own
46
+ // limits.
47
+ if (config.admin.enabled) {
48
+ app.use(ADMIN.PATH_PREFIX, createAdminRouter({
49
+ config,
50
+ adminRepo: dbManager.admin,
51
+ logRepo: dbManager.logs,
52
+ // In the database, so a restart or deploy signs nobody out.
53
+ sessionStore: createDbSessionStore(dbManager, {
54
+ idleMs: config.admin.sessionIdleMs,
55
+ absoluteMs: config.admin.sessionMaxAgeMs,
56
+ }),
57
+ isReady: () => isServerReady,
58
+ geo,
59
+ }));
60
+ }
61
+
62
+ const router = createAnalyticsRouter({
63
+ config,
64
+ dbManager,
65
+ isReady: () => isServerReady,
66
+ geo,
67
+ });
68
+ analyticsRouter = router;
69
+
70
+ // A site missing from CORS_ORIGINS is refused at the browser's preflight;
71
+ // counted, so the tracking log shows it.
72
+ app.use(countRefusedPreflights(config.server.corsOrigins, {
73
+ isTrackingPath: (path) => Boolean(trackingSourceFor(path)),
74
+ onRefused: (req) => router.countRejection(req, REJECTION_REASON.ORIGIN_NOT_ALLOWED, { detail: 'CORS_ORIGINS' }),
75
+ }));
30
76
  app.use(cors(buildCorsOptions(config.server.corsOrigins)));
31
77
 
32
78
  // Bounded well below body-parser's 100kb default; /event is the only
33
79
  // endpoint taking a body and its payload is small.
34
80
  app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
35
81
 
36
- // Never bare `true`. Trusting every hop lets any caller set
37
- // X-Forwarded-For and be believed, which forges geolocation and rotates
38
- // the rate-limiter key at will.
39
- app.set('trust proxy', config.server.trustProxy);
40
-
41
82
  app.use(rateLimit({
42
83
  windowMs: config.server.rateLimit.windowMs,
43
84
  limit: config.server.rateLimit.max,
44
85
  message: { message: 'Too many requests, please try again later.' },
45
86
  standardHeaders: true,
46
87
  legacyHeaders: false,
88
+ // A tracking request turned away here is counted in the tracking log
89
+ // like any other refusal (in memory, written in batches).
90
+ handler: (req, res, next, options) => {
91
+ if (trackingSourceFor(req.path)) {
92
+ router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
93
+ }
94
+ res.status(options.statusCode).json(options.message);
95
+ },
47
96
  }));
48
97
 
49
- app.use(createAnalyticsRouter({
50
- config,
51
- dbManager,
52
- isReady: () => isServerReady,
53
- }));
98
+ app.use(router);
54
99
 
55
- // Malformed JSON and payloads over the limit surface here.
56
- // eslint-disable-next-line no-unused-vars
57
- app.use((err, req, res, next) => {
58
- const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
59
- logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
60
- res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
61
- .json({ message: 'Malformed or oversized request' });
62
- });
100
+ // Malformed JSON and payloads over the limit surface here, and are
101
+ // counted in the tracking log when they were sent to a tracking endpoint.
102
+ app.use(router.bodyErrorHandler);
63
103
 
64
104
  return app;
65
105
  }
@@ -72,7 +112,7 @@ const app = createApp();
72
112
  * Config-declared apps and registry-declared apps are unioned: the file stays
73
113
  * authoritative for a fixed single-operator deployment, while the registry
74
114
  * carries tenants provisioned at runtime. A registry that cannot be read is a
75
- * warning rather than a startup failure — config-declared apps still work.
115
+ * warning rather than a startup failure: config-declared apps still work.
76
116
  */
77
117
  async function mergeRegisteredApps() {
78
118
  try {
@@ -101,6 +141,14 @@ async function mergeRegisteredApps() {
101
141
  const initializeServer = async () => {
102
142
  try {
103
143
  config.validate();
144
+
145
+ // A configured city database that cannot be opened stops startup: the
146
+ // operator asked for it, and running without it would hide that.
147
+ if (config.geo?.cityDatabase) {
148
+ geo.city = await openCityLookup(config.geo.cityDatabase);
149
+ logger.info(`City database loaded (${geo.city.databaseType})`);
150
+ }
151
+
104
152
  await dbManager.initialize(config.allowed.appId);
105
153
 
106
154
  // Merge dynamically registered tenants into the live allowlist, so
@@ -108,6 +156,20 @@ const initializeServer = async () => {
108
156
  // anyone editing allowed.json.
109
157
  await mergeRegisteredApps();
110
158
 
159
+ // initialize() migrated the configured apps; this also covers the ones
160
+ // registered at runtime and merged in above. Idempotent, so apps
161
+ // already in shape cost one information_schema read. Fails startup
162
+ // rather than serving over a half-migrated table.
163
+ await dbManager.migrate(config.allowed.appId);
164
+
165
+ stopRetention = startRetention({
166
+ adminRepo: dbManager.admin,
167
+ logRepo: dbManager.logs,
168
+ getAppIds: () => config.allowed.appId,
169
+ trashDays: config.admin.trashRetentionDays,
170
+ viewLogDays: config.admin.viewLogRetentionDays,
171
+ });
172
+
111
173
  isServerReady = true;
112
174
 
113
175
  if (require.main === module) {
@@ -123,8 +185,8 @@ const initializeServer = async () => {
123
185
  }
124
186
  };
125
187
 
126
- // Only when run directly. Requiring this module as a library — to mount
127
- // createAnalyticsRouter into an existing app — must not validate config,
188
+ // Only when run directly. Requiring this module as a library (to mount
189
+ // createAnalyticsRouter into an existing app) must not validate config,
128
190
  // connect to a database, or bind a port as a side effect of the import.
129
191
  if (require.main === module) {
130
192
  initializeServer();
@@ -147,6 +209,8 @@ const shutdown = async (signal, exitCode = 0) => {
147
209
  forceExit.unref();
148
210
 
149
211
  try {
212
+ stopRetention();
213
+ await analyticsRouter?.flushRejections();
150
214
  if (httpServer) {
151
215
  await new Promise((resolve) => httpServer.close(resolve));
152
216
  }
@@ -179,6 +243,9 @@ process.on('uncaughtException', (error) => {
179
243
  module.exports = app;
180
244
  module.exports.createApp = createApp;
181
245
  module.exports.createAnalyticsRouter = createAnalyticsRouter;
246
+ module.exports.createAdminRouter = createAdminRouter;
247
+ module.exports.startRetention = startRetention;
248
+ module.exports.createDbSessionStore = createDbSessionStore;
182
249
  module.exports.DatabaseManager = DatabaseManager;
183
250
  module.exports.dbManager = dbManager;
184
251
  module.exports.initializeServer = initializeServer;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Admin UI authentication: password login, server-side sessions, CSRF, and a
3
+ * fresh password for the actions that cannot be undone.
4
+ *
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.
9
+ *
10
+ * CSRF is defeated three ways, each sufficient on its own in a modern browser:
11
+ * the cookie is SameSite=Strict, every mutating request must echo a per-session
12
+ * token in a header that a cross-site form cannot set, and a present Origin
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.
15
+ */
16
+
17
+ const crypto = require('crypto');
18
+
19
+ const { ADMIN, ADMIN_ERROR_CODE, HTTP_STATUS } = require('../constants');
20
+ const { safeEqual } = require('./auth');
21
+ const { parseCookies } = require('../utils/cookieUtils');
22
+ const { WarningType, logWarning } = require('../utils/errorUtils');
23
+
24
+ /** Methods that never change state and so need no CSRF token. */
25
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
26
+
27
+ /** @param {string} token @returns {string} */
28
+ function hashToken(token) {
29
+ return crypto.createHash('sha256').update(token).digest('hex');
30
+ }
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
+
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
+ *
58
+ * @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
59
+ * now?: () => number }} [options]
60
+ */
61
+ function createSessionStore({
62
+ idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
63
+ absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
64
+ maxSessions = ADMIN.MAX_SESSIONS,
65
+ now = Date.now,
66
+ } = {}) {
67
+ /** @type {Map<string, {id: string, createdAt: number, lastSeenAt: number, passwordAt: number}>} */
68
+ const sessions = new Map();
69
+
70
+ const view = (session, at) => ({ id: session.id, passwordAgeMs: at - session.passwordAt });
71
+
72
+ return {
73
+ idleMs,
74
+ absoluteMs,
75
+
76
+ async create() {
77
+ const at = now();
78
+ const token = newToken();
79
+ const session = { id: crypto.randomUUID(), createdAt: at, lastSeenAt: at, passwordAt: at };
80
+
81
+ // Bounded: the oldest session goes first. Map preserves insertion
82
+ // order, so the first key is always the oldest.
83
+ while (sessions.size >= maxSessions) {
84
+ sessions.delete(sessions.keys().next().value);
85
+ }
86
+ sessions.set(hashToken(token), session);
87
+ return { token, session: view(session, at) };
88
+ },
89
+
90
+ async get(token) {
91
+ if (typeof token !== 'string' || token.length === 0) return null;
92
+ const key = hashToken(token);
93
+ const session = sessions.get(key);
94
+ if (!session) return null;
95
+
96
+ const at = now();
97
+ if (at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs) {
98
+ sessions.delete(key);
99
+ return null;
100
+ }
101
+ session.lastSeenAt = at;
102
+ return view(session, at);
103
+ },
104
+
105
+ async destroy(token) {
106
+ if (typeof token !== 'string' || token.length === 0) return false;
107
+ return sessions.delete(hashToken(token));
108
+ },
109
+
110
+ async confirmPassword(token) {
111
+ const session = typeof token === 'string' ? sessions.get(hashToken(token)) : null;
112
+ if (session) session.passwordAt = now();
113
+ },
114
+
115
+ get size() {
116
+ return sessions.size;
117
+ },
118
+ };
119
+ }
120
+
121
+ /** Read the session token from the request's cookies. */
122
+ function readToken(req) {
123
+ return parseCookies(req.headers.cookie)[ADMIN.SESSION_COOKIE];
124
+ }
125
+
126
+ /**
127
+ * Cookie attributes. `Secure` follows the connection: a deployment behind a
128
+ * TLS-terminating proxy gets it through `trust proxy`, and plain-HTTP local
129
+ * development still works. Scoped to wherever the admin router is mounted
130
+ * (`req.adminBasePath`), so the cookie never travels to the public analytics
131
+ * endpoints and still works when another app mounts the router elsewhere.
132
+ */
133
+ function cookieOptions(req, maxAge = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS) {
134
+ return {
135
+ httpOnly: true,
136
+ sameSite: 'strict',
137
+ secure: req.secure,
138
+ path: req.adminBasePath || ADMIN.PATH_PREFIX,
139
+ maxAge,
140
+ };
141
+ }
142
+
143
+ /** @returns {import('express').RequestHandler} */
144
+ function requireAdminSession(store) {
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);
157
+ }
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 });
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The origin this request was addressed to, as the browser would write it.
176
+ * @param {import('express').Request} req
177
+ */
178
+ function expectedOrigin(req) {
179
+ return `${req.protocol}://${req.get('host')}`;
180
+ }
181
+
182
+ /**
183
+ * Whether a browser-supplied Origin (absent for same-origin GETs and non-browser
184
+ * clients) matches this server. A mismatch is logged with both values: behind a
185
+ * misconfigured proxy every admin request is refused, and the log is the only
186
+ * place that says why.
187
+ * @param {import('express').Request} req
188
+ */
189
+ function originAllowed(req) {
190
+ const presented = req.get('origin');
191
+ if (!presented) return true;
192
+ const expected = expectedOrigin(req);
193
+ if (presented === expected) return true;
194
+ logWarning(WarningType.ADMIN_ORIGIN_REJECTED, { presented, expected });
195
+ return false;
196
+ }
197
+
198
+ /**
199
+ * Reject a state-changing request that does not carry this session's CSRF
200
+ * token, or that a browser says came from another origin.
201
+ * @returns {import('express').RequestHandler}
202
+ */
203
+ function requireCsrf() {
204
+ return (req, res, next) => {
205
+ if (SAFE_METHODS.has(req.method)) return next();
206
+
207
+ const presented = req.get(ADMIN.CSRF_HEADER) || '';
208
+ const session = req.adminSession;
209
+
210
+ const originOk = originAllowed(req);
211
+ const tokenOk = Boolean(session) && typeof req.adminToken === 'string'
212
+ && safeEqual(presented, sessionCsrfToken(req.adminToken));
213
+
214
+ if (!originOk || !tokenOk) {
215
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
216
+ }
217
+ return next();
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Constant-time password check. An unset password never matches anything,
223
+ * including an empty submission.
224
+ */
225
+ function verifyPassword(presented, configured) {
226
+ if (!configured) return false;
227
+ return safeEqual(String(presented ?? ''), configured);
228
+ }
229
+
230
+ module.exports = {
231
+ createSessionStore,
232
+ requireAdminSession,
233
+ requireRecentPassword,
234
+ requireCsrf,
235
+ sessionCsrfToken,
236
+ newToken,
237
+ verifyPassword,
238
+ cookieOptions,
239
+ expectedOrigin,
240
+ originAllowed,
241
+ readToken,
242
+ hashToken,
243
+ SAFE_METHODS,
244
+ };